diff --git a/internal/server/handlers_file.go b/internal/server/handlers_file.go index 1b28240..9bb08e8 100644 --- a/internal/server/handlers_file.go +++ b/internal/server/handlers_file.go @@ -16,10 +16,19 @@ import ( "github.com/lukaszraczylo/mcp-filepuff/internal/parser" "github.com/lukaszraczylo/mcp-filepuff/internal/search" "github.com/lukaszraczylo/mcp-filepuff/pkg/errors" + "github.com/lukaszraczylo/mcp-filepuff/pkg/fuzzy" "github.com/lukaszraczylo/mcp-filepuff/pkg/protocol" "github.com/mark3labs/mcp-go/mcp" ) +// Symbol-suggestion tuning for not-found symbol_name reads. +const ( + // maxSymbolSuggestionDistance is the max fuzzy edit distance for a suggestion. + maxSymbolSuggestionDistance = 3 + // maxSymbolSuggestions caps how many "did you mean" names are listed. + maxSymbolSuggestions = 3 +) + // handleFileSearch handles the file_search tool. func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { start := time.Now() @@ -443,7 +452,7 @@ func (s *Server) readOneFile(ctx context.Context, request mcp.CallToolRequest, p candidates := s.resolveSymbolCandidates(ctx, path, content, symbolName, symbolKind) switch len(candidates) { case 0: - return "", fmt.Errorf("symbol %q not found in %s", symbolName, path) + return "", fmt.Errorf("symbol %q not found in %s%s", symbolName, path, s.nearestSymbolSuggestion(ctx, path, content, symbolName)) case 1: lineStart = candidates[0].StartLine lineEnd = candidates[0].EndLine @@ -518,6 +527,41 @@ func (s *Server) resolveSymbolCandidates(ctx context.Context, path string, conte return parser.FindSymbolCandidates(result.Tree, content, path, symbolName, symbolKind) } +// nearestSymbolSuggestion returns a " — did you mean: a, b, c?" hint listing the +// closest symbol names in the file to name, or "" if none are reasonably close. +// The re-parse is cheap: the tree is served from the parser cache. +func (s *Server) nearestSymbolSuggestion(ctx context.Context, path string, content []byte, name string) string { + result, err := s.parser.Parse(ctx, path, content) + if err != nil { + return "" + } + syms := parser.ExtractSymbols(result.Tree, content, result.Language, path) + if len(syms) == 0 { + return "" + } + names := make([]string, 0, len(syms)) + seen := make(map[string]bool, len(syms)) + for _, sym := range syms { + if !seen[sym.Name] { + seen[sym.Name] = true + names = append(names, sym.Name) + } + } + matches := fuzzy.New(maxSymbolSuggestionDistance).Match(name, names) + if len(matches) == 0 { + return "" + } + top := matches + if len(top) > maxSymbolSuggestions { + top = top[:maxSymbolSuggestions] + } + suggestions := make([]string, len(top)) + for i, m := range top { + suggestions[i] = m.Text + } + return " — did you mean: " + strings.Join(suggestions, ", ") + "?" +} + // formatSymbolCandidates renders ambiguous symbol matches as a compact, // comma-separated list, e.g. "method_declaration L10-24, method_declaration L42-58". func formatSymbolCandidates(candidates []parser.SymbolCandidate) string { diff --git a/internal/server/handlers_file_test.go b/internal/server/handlers_file_test.go index da8c583..871cf68 100644 --- a/internal/server/handlers_file_test.go +++ b/internal/server/handlers_file_test.go @@ -266,6 +266,28 @@ struct Foo { // ---- Feature 2: strip flag ---- +// A misspelled symbol_name should fail with a "did you mean" hint listing the +// closest real symbols, so the model can self-correct. +func TestReadSymbolNotFoundSuggestsNearest(t *testing.T) { + tmpDir := t.TempDir() + srv := newTestServer(t, tmpDir) + + goSrc := "package main\n\nfunc Hello() {}\n\nfunc Goodbye() {}\n" + f := writeFile(t, tmpDir, "main.go", goSrc) + + out := callRead(t, srv, map[string]interface{}{ + "path": f, + "symbol_name": "Helo", // typo for Hello + }) + + if !strings.Contains(out, "not found") { + t.Errorf("expected not-found error, got:\n%s", out) + } + if !strings.Contains(out, "did you mean") || !strings.Contains(out, "Hello") { + t.Errorf("expected suggestion of Hello, got:\n%s", out) + } +} + func TestStripImportsGo(t *testing.T) { tmpDir := t.TempDir() srv := newTestServer(t, tmpDir)