Merge remote-tracking branch 'origin/main' into prompts

Carlos Alexandro Becker created

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>

Change summary

.github/cla-signatures.json          |   8 +
CRUSH.md                             |   2 
Taskfile.yaml                        |   2 
go.mod                               |   2 
go.sum                               |   4 
internal/cmd/dirs_test.go            |  46 ++++++
internal/llm/agent/agent.go          |   2 
internal/llm/agent/mcp-tools.go      |  44 +++++
internal/llm/tools/diagnostics.go    |   2 
internal/llm/tools/grep.go           | 108 +++++++--------
internal/llm/tools/grep_test.go      |  29 ++++
internal/llm/tools/references.go     | 214 ++++++++++++++++++++++++++++++
internal/llm/tools/references.md     |  36 +++++
internal/llm/tools/rg.go             |   2 
internal/llm/tools/testdata/grep.txt |   3 
internal/lsp/client.go               |  10 +
16 files changed, 445 insertions(+), 69 deletions(-)

Detailed changes

.github/cla-signatures.json 🔗

@@ -711,6 +711,14 @@
       "created_at": "2025-10-13T05:56:20Z",
       "repoId": 987670088,
       "pullRequestNo": 1223
+    },
+    {
+      "name": "BrunoKrugel",
+      "id": 30608179,
+      "comment_id": 3411978929,
+      "created_at": "2025-10-16T17:30:07Z",
+      "repoId": 987670088,
+      "pullRequestNo": 1245
     }
   ]
 }

CRUSH.md 🔗

@@ -54,7 +54,7 @@ func TestYourFunction(t *testing.T) {
 ## Formatting
 
 - ALWAYS format any Go code you write.
-  - First, try `goftumpt -w .`.
+  - First, try `gofumpt -w .`.
   - If `gofumpt` is not available, use `goimports`.
   - If `goimports` is not available, use `gofmt`.
   - You can also use `task fmt` to run `gofumpt -w .` on the entire project,

Taskfile.yaml 🔗

@@ -99,7 +99,7 @@ tasks:
     cmds:
       - task: fetch-tags
       - git commit --allow-empty -m "{{.NEXT}}"
-      - git tag --annotate -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}}
+      - git tag --annotate --sign -m "{{.NEXT}}" {{.NEXT}} {{.CLI_ARGS}}
       - echo "Pushing {{.NEXT}}..."
       - git push origin main --follow-tags
 

go.mod 🔗

@@ -77,7 +77,7 @@ require (
 	github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef
 	github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect
 	github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d
-	github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4
+	github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4
 	github.com/charmbracelet/x/term v0.2.1
 	github.com/charmbracelet/x/termios v0.1.1 // indirect
 	github.com/charmbracelet/x/windows v0.2.2 // indirect

go.sum 🔗

@@ -106,8 +106,8 @@ github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sB
 github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
 github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d h1:H2oh4WlSsXy8qwLd7I3eAvPd/X3S40aM9l+h47WF1eA=
 github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms=
-github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4 h1:ZhDGU688EHQXslD9KphRpXwK0pKP03egUoZAATUDlV0=
-github.com/charmbracelet/x/powernap v0.0.0-20250919153222-1038f7e6fef4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
+github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4 h1:i/XilBPYK4L1Yo/mc9FPx0SyJzIsN0y4sj1MWq9Sscc=
+github.com/charmbracelet/x/powernap v0.0.0-20251015113943-25f979b54ad4/go.mod h1:cmdl5zlP5mR8TF2Y68UKc7hdGUDiSJ2+4hk0h04Hsx4=
 github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
 github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
 github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=

internal/cmd/dirs_test.go 🔗

@@ -0,0 +1,46 @@
+package cmd
+
+import (
+	"bytes"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func init() {
+	os.Setenv("XDG_CONFIG_HOME", "/tmp/fakeconfig")
+	os.Setenv("XDG_DATA_HOME", "/tmp/fakedata")
+}
+
+func TestDirs(t *testing.T) {
+	var b bytes.Buffer
+	dirsCmd.SetOut(&b)
+	dirsCmd.SetErr(&b)
+	dirsCmd.SetIn(bytes.NewReader(nil))
+	dirsCmd.Run(dirsCmd, nil)
+	expected := filepath.FromSlash("/tmp/fakeconfig/crush") + "\n" +
+		filepath.FromSlash("/tmp/fakedata/crush") + "\n"
+	require.Equal(t, expected, b.String())
+}
+
+func TestConfigDir(t *testing.T) {
+	var b bytes.Buffer
+	configDirCmd.SetOut(&b)
+	configDirCmd.SetErr(&b)
+	configDirCmd.SetIn(bytes.NewReader(nil))
+	configDirCmd.Run(configDirCmd, nil)
+	expected := filepath.FromSlash("/tmp/fakeconfig/crush") + "\n"
+	require.Equal(t, expected, b.String())
+}
+
+func TestDataDir(t *testing.T) {
+	var b bytes.Buffer
+	dataDirCmd.SetOut(&b)
+	dataDirCmd.SetErr(&b)
+	dataDirCmd.SetIn(bytes.NewReader(nil))
+	dataDirCmd.Run(dataDirCmd, nil)
+	expected := filepath.FromSlash("/tmp/fakedata/crush") + "\n"
+	require.Equal(t, expected, b.String())
+}

internal/llm/agent/agent.go 🔗

@@ -525,7 +525,7 @@ func (a *agent) getAllTools() ([]tools.BaseTool, error) {
 	if a.agentCfg.ID == "coder" {
 		allTools = slices.AppendSeq(allTools, a.mcpTools.Seq())
 		if a.lspClients.Len() > 0 {
-			allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients))
+			allTools = append(allTools, tools.NewDiagnosticsTool(a.lspClients), tools.NewReferencesTool(a.lspClients))
 		}
 	}
 	if a.agentToolFn != nil {

internal/llm/agent/mcp-tools.go 🔗

@@ -10,6 +10,7 @@ import (
 	"log/slog"
 	"maps"
 	"net/http"
+	"os"
 	"os/exec"
 	"strings"
 	"sync"
@@ -106,8 +107,8 @@ func (b *McpTool) Name() string {
 }
 
 func (b *McpTool) Info() tools.ToolInfo {
-	var parameters map[string]any
-	var required []string
+	parameters := make(map[string]any)
+	required := make([]string, 0)
 
 	if input, ok := b.tool.InputSchema.(map[string]any); ok {
 		if props, ok := input["properties"].(map[string]any); ok {
@@ -380,6 +381,8 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso
 	if err != nil {
 		updateMCPState(name, MCPStateError, err, nil, MCPCounts{})
 		slog.Error("error creating mcp client", "error", err, "name", name)
+		cancel()
+		cancelTimer.Stop()
 		return nil, err
 	}
 
@@ -408,9 +411,11 @@ func createMCPSession(ctx context.Context, name string, m config.MCPConfig, reso
 
 	session, err := client.Connect(mcpCtx, transport, nil)
 	if err != nil {
+		err = maybeStdioErr(err, transport)
 		updateMCPState(name, MCPStateError, maybeTimeoutErr(err, timeout), nil, MCPCounts{})
 		slog.Error("error starting mcp client", "error", err, "name", name)
 		cancel()
+		cancelTimer.Stop()
 		return nil, err
 	}
 
@@ -437,7 +442,7 @@ func createMCPTransport(ctx context.Context, m config.MCPConfig, resolver config
 			return nil, fmt.Errorf("mcp stdio config requires a non-empty 'command' field")
 		}
 		cmd := exec.CommandContext(ctx, home.Long(command), m.Args...)
-		cmd.Env = m.ResolvedEnv()
+		cmd.Env = append(os.Environ(), m.ResolvedEnv()...)
 		return &mcp.CommandTransport{
 			Command: cmd,
 		}, nil
@@ -540,3 +545,36 @@ func GetMCPPromptContent(ctx context.Context, clientName, promptName string, arg
 		Arguments: args,
 	})
 }
+
+// maybeStdioErr if a stdio mcp prints an error in non-json format, it'll fail
+// to parse, and the cli will then close it, causing the EOF error.
+// so, if we got an EOF err, and the transport is STDIO, we try to exec it
+// again with a timeout and collect the output so we can add details to the
+// error.
+// this happens particularly when starting things with npx, e.g. if node can't
+// be found or some other error like that.
+func maybeStdioErr(err error, transport mcp.Transport) error {
+	if !errors.Is(err, io.EOF) {
+		return err
+	}
+	ct, ok := transport.(*mcp.CommandTransport)
+	if !ok {
+		return err
+	}
+	if err2 := stdioMCPCheck(ct.Command); err2 != nil {
+		err = errors.Join(err, err2)
+	}
+	return err
+}
+
+func stdioMCPCheck(old *exec.Cmd) error {
+	ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
+	defer cancel()
+	cmd := exec.CommandContext(ctx, old.Path, old.Args...)
+	cmd.Env = old.Env
+	out, err := cmd.CombinedOutput()
+	if err == nil || errors.Is(ctx.Err(), context.DeadlineExceeded) {
+		return nil
+	}
+	return fmt.Errorf("%w: %s", err, string(out))
+}

internal/llm/tools/diagnostics.go 🔗

@@ -23,7 +23,7 @@ type diagnosticsTool struct {
 	lspClients *csync.Map[string, *lsp.Client]
 }
 
-const DiagnosticsToolName = "diagnostics"
+const DiagnosticsToolName = "lsp_diagnostics"
 
 //go:embed diagnostics.md
 var diagnosticsDescription []byte

internal/llm/tools/grep.go 🔗

@@ -2,6 +2,7 @@ package tools
 
 import (
 	"bufio"
+	"bytes"
 	"context"
 	_ "embed"
 	"encoding/json"
@@ -13,7 +14,6 @@ import (
 	"path/filepath"
 	"regexp"
 	"sort"
-	"strconv"
 	"strings"
 	"sync"
 	"time"
@@ -82,6 +82,7 @@ type grepMatch struct {
 	path     string
 	modTime  time.Time
 	lineNum  int
+	charNum  int
 	lineText string
 }
 
@@ -189,7 +190,11 @@ func (g *grepTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error)
 				fmt.Fprintf(&output, "%s:\n", match.path)
 			}
 			if match.lineNum > 0 {
-				fmt.Fprintf(&output, "  Line %d: %s\n", match.lineNum, match.lineText)
+				if match.charNum > 0 {
+					fmt.Fprintf(&output, "  Line %d, Char %d: %s\n", match.lineNum, match.charNum, match.lineText)
+				} else {
+					fmt.Fprintf(&output, "  Line %d: %s\n", match.lineNum, match.lineText)
+				}
 			} else {
 				fmt.Fprintf(&output, "  %s\n", match.path)
 			}
@@ -252,66 +257,51 @@ func searchWithRipgrep(ctx context.Context, pattern, path, include string) ([]gr
 		return nil, err
 	}
 
-	lines := strings.Split(strings.TrimSpace(string(output)), "\n")
-	matches := make([]grepMatch, 0, len(lines))
-
-	for _, line := range lines {
-		if line == "" {
+	var matches []grepMatch
+	for line := range bytes.SplitSeq(bytes.TrimSpace(output), []byte{'\n'}) {
+		if len(line) == 0 {
 			continue
 		}
-
-		// Parse ripgrep output using null separation
-		filePath, lineNumStr, lineText, ok := parseRipgrepLine(line)
-		if !ok {
+		var match ripgrepMatch
+		if err := json.Unmarshal(line, &match); err != nil {
 			continue
 		}
-
-		lineNum, err := strconv.Atoi(lineNumStr)
-		if err != nil {
+		if match.Type != "match" {
 			continue
 		}
-
-		fileInfo, err := os.Stat(filePath)
-		if err != nil {
-			continue // Skip files we can't access
+		for _, m := range match.Data.Submatches {
+			fi, err := os.Stat(match.Data.Path.Text)
+			if err != nil {
+				continue // Skip files we can't access
+			}
+			matches = append(matches, grepMatch{
+				path:     match.Data.Path.Text,
+				modTime:  fi.ModTime(),
+				lineNum:  match.Data.LineNumber,
+				charNum:  m.Start + 1, // ensure 1-based
+				lineText: strings.TrimSpace(match.Data.Lines.Text),
+			})
+			// only get the first match of each line
+			break
 		}
-
-		matches = append(matches, grepMatch{
-			path:     filePath,
-			modTime:  fileInfo.ModTime(),
-			lineNum:  lineNum,
-			lineText: lineText,
-		})
 	}
-
 	return matches, nil
 }
 
-// parseRipgrepLine parses ripgrep output with null separation to handle Windows paths
-func parseRipgrepLine(line string) (filePath, lineNum, lineText string, ok bool) {
-	// Split on null byte first to separate filename from rest
-	parts := strings.SplitN(line, "\x00", 2)
-	if len(parts) != 2 {
-		return "", "", "", false
-	}
-
-	filePath = parts[0]
-	remainder := parts[1]
-
-	// Now split the remainder on first colon: "linenum:content"
-	colonIndex := strings.Index(remainder, ":")
-	if colonIndex == -1 {
-		return "", "", "", false
-	}
-
-	lineNumStr := remainder[:colonIndex]
-	lineText = remainder[colonIndex+1:]
-
-	if _, err := strconv.Atoi(lineNumStr); err != nil {
-		return "", "", "", false
-	}
-
-	return filePath, lineNumStr, lineText, true
+type ripgrepMatch struct {
+	Type string `json:"type"`
+	Data struct {
+		Path struct {
+			Text string `json:"text"`
+		} `json:"path"`
+		Lines struct {
+			Text string `json:"text"`
+		} `json:"lines"`
+		LineNumber int `json:"line_number"`
+		Submatches []struct {
+			Start int `json:"start"`
+		} `json:"submatches"`
+	} `json:"data"`
 }
 
 func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error) {
@@ -363,7 +353,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
 			return nil
 		}
 
-		match, lineNum, lineText, err := fileContainsPattern(path, regex)
+		match, lineNum, charNum, lineText, err := fileContainsPattern(path, regex)
 		if err != nil {
 			return nil // Skip files we can't read
 		}
@@ -373,6 +363,7 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
 				path:     path,
 				modTime:  info.ModTime(),
 				lineNum:  lineNum,
+				charNum:  charNum,
 				lineText: lineText,
 			})
 
@@ -390,15 +381,15 @@ func searchFilesWithRegex(pattern, rootPath, include string) ([]grepMatch, error
 	return matches, nil
 }
 
-func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, string, error) {
+func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, int, string, error) {
 	// Only search text files.
 	if !isTextFile(filePath) {
-		return false, 0, "", nil
+		return false, 0, 0, "", nil
 	}
 
 	file, err := os.Open(filePath)
 	if err != nil {
-		return false, 0, "", err
+		return false, 0, 0, "", err
 	}
 	defer file.Close()
 
@@ -407,12 +398,13 @@ func fileContainsPattern(filePath string, pattern *regexp.Regexp) (bool, int, st
 	for scanner.Scan() {
 		lineNum++
 		line := scanner.Text()
-		if pattern.MatchString(line) {
-			return true, lineNum, line, nil
+		if loc := pattern.FindStringIndex(line); loc != nil {
+			charNum := loc[0] + 1
+			return true, lineNum, charNum, line, nil
 		}
 	}
 
-	return false, 0, "", scanner.Err()
+	return false, 0, 0, "", scanner.Err()
 }
 
 // isTextFile checks if a file is a text file by examining its MIME type.

internal/llm/tools/grep_test.go 🔗

@@ -390,3 +390,32 @@ func TestIsTextFile(t *testing.T) {
 		})
 	}
 }
+
+func TestColumnMatch(t *testing.T) {
+	t.Parallel()
+
+	// Test both implementations
+	for name, fn := range map[string]func(pattern, path, include string) ([]grepMatch, error){
+		"regex": searchFilesWithRegex,
+		"rg": func(pattern, path, include string) ([]grepMatch, error) {
+			return searchWithRipgrep(t.Context(), pattern, path, include)
+		},
+	} {
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			if name == "rg" && getRg() == "" {
+				t.Skip("rg is not in $PATH")
+			}
+
+			matches, err := fn("THIS", "./testdata/", "")
+			require.NoError(t, err)
+			require.Len(t, matches, 1)
+			match := matches[0]
+			require.Equal(t, 2, match.lineNum)
+			require.Equal(t, 14, match.charNum)
+			require.Equal(t, "I wanna grep THIS particular word", match.lineText)
+			require.Equal(t, "testdata/grep.txt", filepath.ToSlash(filepath.Clean(match.path)))
+		})
+	}
+}

internal/llm/tools/references.go 🔗

@@ -0,0 +1,214 @@
+package tools
+
+import (
+	"cmp"
+	"context"
+	_ "embed"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"log/slog"
+	"maps"
+	"path/filepath"
+	"regexp"
+	"slices"
+	"sort"
+	"strings"
+
+	"github.com/charmbracelet/crush/internal/csync"
+	"github.com/charmbracelet/crush/internal/lsp"
+	"github.com/charmbracelet/x/powernap/pkg/lsp/protocol"
+)
+
+type ReferencesParams struct {
+	Symbol string `json:"symbol"`
+	Path   string `json:"path"`
+}
+
+type referencesTool struct {
+	lspClients *csync.Map[string, *lsp.Client]
+}
+
+const ReferencesToolName = "lsp_references"
+
+//go:embed references.md
+var referencesDescription []byte
+
+func NewReferencesTool(lspClients *csync.Map[string, *lsp.Client]) BaseTool {
+	return &referencesTool{
+		lspClients,
+	}
+}
+
+func (r *referencesTool) Name() string {
+	return ReferencesToolName
+}
+
+func (r *referencesTool) Info() ToolInfo {
+	return ToolInfo{
+		Name:        ReferencesToolName,
+		Description: string(referencesDescription),
+		Parameters: map[string]any{
+			"symbol": map[string]any{
+				"type":        "string",
+				"description": "The symbol name to search for (e.g., function name, variable name, type name).",
+			},
+			"path": map[string]any{
+				"type":        "string",
+				"description": "The directory to search in. Should be the entire project most of the time. Defaults to the current working directory.",
+			},
+		},
+		Required: []string{"symbol"},
+	}
+}
+
+func (r *referencesTool) Run(ctx context.Context, call ToolCall) (ToolResponse, error) {
+	var params ReferencesParams
+	if err := json.Unmarshal([]byte(call.Input), &params); err != nil {
+		return NewTextErrorResponse(fmt.Sprintf("error parsing parameters: %s", err)), nil
+	}
+
+	if params.Symbol == "" {
+		return NewTextErrorResponse("symbol is required"), nil
+	}
+
+	if r.lspClients.Len() == 0 {
+		return NewTextErrorResponse("no LSP clients available"), nil
+	}
+
+	workingDir := cmp.Or(params.Path, ".")
+
+	matches, _, err := searchFiles(ctx, regexp.QuoteMeta(params.Symbol), workingDir, "", 100)
+	if err != nil {
+		return NewTextErrorResponse(fmt.Sprintf("failed to search for symbol: %s", err)), nil
+	}
+
+	if len(matches) == 0 {
+		return NewTextResponse(fmt.Sprintf("Symbol '%s' not found", params.Symbol)), nil
+	}
+
+	var allLocations []protocol.Location
+	var allErrs error
+	for _, match := range matches {
+		locations, err := r.find(ctx, params.Symbol, match)
+		if err != nil {
+			if strings.Contains(err.Error(), "no identifier found") {
+				// grep probably matched a comment, string value, or something else that's irrelevant
+				continue
+			}
+			slog.Error("Failed to find references", "error", err, "symbol", params.Symbol, "path", match.path, "line", match.lineNum, "char", match.charNum)
+			allErrs = errors.Join(allErrs, err)
+			continue
+		}
+		allLocations = append(allLocations, locations...)
+		// XXX: should we break here or look for all results?
+	}
+
+	if len(allLocations) > 0 {
+		output := formatReferences(cleanupLocations(allLocations))
+		return NewTextResponse(output), nil
+	}
+
+	if allErrs != nil {
+		return NewTextErrorResponse(allErrs.Error()), nil
+	}
+	return NewTextResponse(fmt.Sprintf("No references found for symbol '%s'", params.Symbol)), nil
+}
+
+func (r *referencesTool) find(ctx context.Context, symbol string, match grepMatch) ([]protocol.Location, error) {
+	absPath, err := filepath.Abs(match.path)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get absolute path: %s", err)
+	}
+
+	var client *lsp.Client
+	for c := range r.lspClients.Seq() {
+		if c.HandlesFile(absPath) {
+			client = c
+			break
+		}
+	}
+
+	if client == nil {
+		slog.Warn("No LSP clients to handle", "path", match.path)
+		return nil, nil
+	}
+
+	return client.FindReferences(
+		ctx,
+		absPath,
+		match.lineNum,
+		match.charNum+getSymbolOffset(symbol),
+		true,
+	)
+}
+
+// getSymbolOffset returns the character offset to the actual symbol name
+// in a qualified symbol (e.g., "Bar" in "foo.Bar" or "method" in "Class::method").
+func getSymbolOffset(symbol string) int {
+	// Check for :: separator (Rust, C++, Ruby modules/classes, PHP static).
+	if idx := strings.LastIndex(symbol, "::"); idx != -1 {
+		return idx + 2
+	}
+	// Check for . separator (Go, Python, JavaScript, Java, C#, Ruby methods).
+	if idx := strings.LastIndex(symbol, "."); idx != -1 {
+		return idx + 1
+	}
+	// Check for \ separator (PHP namespaces).
+	if idx := strings.LastIndex(symbol, "\\"); idx != -1 {
+		return idx + 1
+	}
+	return 0
+}
+
+func cleanupLocations(locations []protocol.Location) []protocol.Location {
+	slices.SortFunc(locations, func(a, b protocol.Location) int {
+		if a.URI != b.URI {
+			return strings.Compare(string(a.URI), string(b.URI))
+		}
+		if a.Range.Start.Line != b.Range.Start.Line {
+			return cmp.Compare(a.Range.Start.Line, b.Range.Start.Line)
+		}
+		return cmp.Compare(a.Range.Start.Character, b.Range.Start.Character)
+	})
+	return slices.CompactFunc(locations, func(a, b protocol.Location) bool {
+		return a.URI == b.URI &&
+			a.Range.Start.Line == b.Range.Start.Line &&
+			a.Range.Start.Character == b.Range.Start.Character
+	})
+}
+
+func groupByFilename(locations []protocol.Location) map[string][]protocol.Location {
+	files := make(map[string][]protocol.Location)
+	for _, loc := range locations {
+		path, err := loc.URI.Path()
+		if err != nil {
+			slog.Error("Failed to convert location URI to path", "uri", loc.URI, "error", err)
+			continue
+		}
+		files[path] = append(files[path], loc)
+	}
+	return files
+}
+
+func formatReferences(locations []protocol.Location) string {
+	fileRefs := groupByFilename(locations)
+	files := slices.Collect(maps.Keys(fileRefs))
+	sort.Strings(files)
+
+	var output strings.Builder
+	output.WriteString(fmt.Sprintf("Found %d reference(s) in %d file(s):\n\n", len(locations), len(files)))
+
+	for _, file := range files {
+		refs := fileRefs[file]
+		output.WriteString(fmt.Sprintf("%s (%d reference(s)):\n", file, len(refs)))
+		for _, ref := range refs {
+			line := ref.Range.Start.Line + 1
+			char := ref.Range.Start.Character + 1
+			output.WriteString(fmt.Sprintf("  Line %d, Column %d\n", line, char))
+		}
+		output.WriteString("\n")
+	}
+
+	return output.String()
+}

internal/llm/tools/references.md 🔗

@@ -0,0 +1,36 @@
+Find all references to/usage of a symbol by name using the Language Server Protocol (LSP).
+
+WHEN TO USE THIS TOOL:
+
+- **ALWAYS USE THIS FIRST** when searching for where a function, method, variable, type, or constant is used
+- **DO NOT use grep/glob for symbol searches** - this tool is semantic-aware and much more accurate
+- Use when you need to find all usages of a specific symbol (function, variable, type, class, method, etc.)
+- More accurate than grep because it understands code semantics and scope
+- Finds only actual references, not string matches in comments or unrelated code
+- Helpful for understanding where a symbol is used throughout the codebase
+- Useful for refactoring or analyzing code dependencies
+- Good for finding all call sites of a function, method, type, package, constant, variable, etc.
+
+HOW TO USE:
+
+- Provide the symbol name (e.g., "MyFunction", "myVariable", "MyType")
+- Optionally specify a path to narrow the search to a specific directory
+- The tool will automatically find the symbol and locate all references
+
+FEATURES:
+
+- Returns all references grouped by file
+- Shows line and column numbers for each reference
+- Supports multiple programming languages through LSP
+- Automatically finds the symbol without needing exact position
+
+LIMITATIONS:
+
+- May not find references in files that haven't been opened or indexed
+- Results depend on the LSP server's capabilities
+
+TIPS:
+
+- **Use this tool instead of grep when looking for symbol references** - it's more accurate and semantic-aware
+- Simply provide the symbol name and let the tool find it for you
+- This tool understands code structure, so it won't match unrelated strings or comments

internal/llm/tools/rg.go 🔗

@@ -43,7 +43,7 @@ func getRgSearchCmd(ctx context.Context, pattern, path, include string) *exec.Cm
 		return nil
 	}
 	// Use -n to show line numbers, -0 for null separation to handle Windows paths
-	args := []string{"-H", "-n", "-0", pattern}
+	args := []string{"--json", "-H", "-n", "-0", pattern}
 	if include != "" {
 		args = append(args, "--glob", include)
 	}

internal/lsp/client.go 🔗

@@ -445,6 +445,16 @@ func (c *Client) WaitForDiagnostics(ctx context.Context, d time.Duration) {
 	}
 }
 
+// FindReferences finds all references to the symbol at the given position.
+func (c *Client) FindReferences(ctx context.Context, filepath string, line, character int, includeDeclaration bool) ([]protocol.Location, error) {
+	if err := c.OpenFileOnDemand(ctx, filepath); err != nil {
+		return nil, err
+	}
+	// NOTE: line and character should be 0-based.
+	// See: https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#position
+	return c.client.FindReferences(ctx, filepath, line-1, character-1, includeDeclaration)
+}
+
 // HasRootMarkers checks if any of the specified root marker patterns exist in the given directory.
 // Uses glob patterns to match files, allowing for more flexible matching.
 func HasRootMarkers(dir string, rootMarkers []string) bool {