feat(read): suggest nearest symbols when symbol_name not found

A misspelled or stale symbol_name returned a bare 'not found' error, forcing blind retries. nearestSymbolSuggestion fuzzy-matches the requested name against the file's actual symbols (via existing pkg/fuzzy) and appends 'did you mean: a, b, c?'. The re-parse is served from the parser cache. Adds TestReadSymbolNotFoundSuggestsNearest.
This commit is contained in:
2026-06-20 00:33:32 +01:00
parent 7b8914017c
commit 1c26f8d105
2 changed files with 67 additions and 1 deletions
+45 -1
View File
@@ -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 {
+22
View File
@@ -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)