mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-12 04:54:27 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user