mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-10-13 04:58:43 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a11623cb0d | ||
|
|
5f0bb90e28 | ||
|
|
62971c9f8c | ||
|
|
99514ff8d6 | ||
|
|
2fb9d3e379 | ||
|
|
3195e353e2 |
@@ -864,7 +864,7 @@ func truncateString(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen-3] + "..."
|
||||
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
|
||||
}
|
||||
|
||||
// ValidateLanguage checks if AST editing is supported for a file.
|
||||
|
||||
+59
-21
@@ -1,6 +1,8 @@
|
||||
package parser
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
sitter "github.com/smacker/go-tree-sitter"
|
||||
)
|
||||
@@ -425,29 +427,65 @@ func extractCSymbols(root *sitter.Node, content []byte, filename string) []proto
|
||||
return symbols
|
||||
}
|
||||
|
||||
// cDeclaratorName descends the declarator field-chain (pointer/reference/array/
|
||||
// function declarators) to the innermost name node and returns its text. Unlike a
|
||||
// first-identifier scan, this returns the function/method NAME (not a parameter)
|
||||
// and handles C++ method names (field_identifier), out-of-line definitions
|
||||
// (qualified_identifier like User::foo), destructors and operators.
|
||||
func cDeclaratorName(declarator *sitter.Node, content []byte) string {
|
||||
node := declarator
|
||||
for node != nil {
|
||||
switch node.Type() {
|
||||
case "identifier", "field_identifier", "qualified_identifier",
|
||||
"destructor_name", "operator_name":
|
||||
return GetNodeText(node, content)
|
||||
}
|
||||
next := node.ChildByFieldName("declarator")
|
||||
if next == nil {
|
||||
return ""
|
||||
}
|
||||
node = next
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// enclosingCppClassName returns the name of the nearest class/struct ancestor of
|
||||
// n (for qualifying in-class method names), or "" if n is not inside one.
|
||||
func enclosingCppClassName(n *sitter.Node, content []byte) string {
|
||||
for p := n.Parent(); p != nil; p = p.Parent() {
|
||||
if p.Type() == "class_specifier" || p.Type() == "struct_specifier" {
|
||||
if name := p.ChildByFieldName("name"); name != nil {
|
||||
return GetNodeText(name, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func extractCFunction(n *sitter.Node, content []byte, filename string) *protocol.Symbol {
|
||||
declarator := n.ChildByFieldName("declarator")
|
||||
if declarator == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find the function name within the declarator
|
||||
var name string
|
||||
WalkTree(declarator, func(node *sitter.Node) bool {
|
||||
if node.Type() == "identifier" {
|
||||
name = GetNodeText(node, content)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
name := cDeclaratorName(declarator, content)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// In-class definitions are methods; qualify the name with the class. Out-of-line
|
||||
// definitions already carry a qualified name (Class::method).
|
||||
kind := protocol.SymbolFunction
|
||||
if cls := enclosingCppClassName(n, content); cls != "" {
|
||||
kind = protocol.SymbolMethod
|
||||
name = cls + "::" + name
|
||||
} else if strings.Contains(name, "::") {
|
||||
kind = protocol.SymbolMethod
|
||||
}
|
||||
|
||||
return &protocol.Symbol{
|
||||
Name: name,
|
||||
Kind: protocol.SymbolFunction,
|
||||
Kind: kind,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
@@ -484,22 +522,22 @@ func extractCFunctionDecl(n *sitter.Node, content []byte, filename string) *prot
|
||||
return nil
|
||||
}
|
||||
|
||||
var name string
|
||||
WalkTree(declarator, func(node *sitter.Node) bool {
|
||||
if node.Type() == "identifier" {
|
||||
name = GetNodeText(node, content)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
name := cDeclaratorName(declarator, content)
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
kind := protocol.SymbolFunction
|
||||
if cls := enclosingCppClassName(n, content); cls != "" {
|
||||
kind = protocol.SymbolMethod
|
||||
name = cls + "::" + name
|
||||
} else if strings.Contains(name, "::") {
|
||||
kind = protocol.SymbolMethod
|
||||
}
|
||||
|
||||
return &protocol.Symbol{
|
||||
Name: name,
|
||||
Kind: protocol.SymbolFunction,
|
||||
Kind: kind,
|
||||
Location: NodeLocation(n, filename),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,36 @@ var globalVar = "test"
|
||||
|
||||
// Grouped var/const specs declare multiple names on one spec (var a, b = 1, 2);
|
||||
// every name must surface as its own symbol, not just the first.
|
||||
// C++ in-class methods must be extracted as methods qualified by their class
|
||||
// (Class::method), not misnamed after a parameter or dropped entirely.
|
||||
func TestExtractCppMethods(t *testing.T) {
|
||||
r := NewRegistry()
|
||||
defer r.Close()
|
||||
content := "class User {\npublic:\n void setName(const char* n) { name_ = n; }\n int id() const { return id_; }\nprivate:\n const char* name_;\n int id_;\n};\n\nvoid freeFunc() {}\n"
|
||||
result, err := r.Parse(context.Background(), "u.cpp", []byte(content))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
syms := ExtractSymbols(result.Tree, []byte(content), protocol.LangCpp, "u.cpp")
|
||||
|
||||
kindByName := make(map[string]protocol.SymbolKind, len(syms))
|
||||
for _, s := range syms {
|
||||
kindByName[s.Name] = s.Kind
|
||||
}
|
||||
if kindByName["User::setName"] != protocol.SymbolMethod {
|
||||
t.Errorf("setName: want method User::setName, got %v", kindByName)
|
||||
}
|
||||
if kindByName["User::id"] != protocol.SymbolMethod {
|
||||
t.Errorf("id: want method User::id, got %v", kindByName)
|
||||
}
|
||||
if kindByName["freeFunc"] != protocol.SymbolFunction {
|
||||
t.Errorf("freeFunc: want function, got %v", kindByName)
|
||||
}
|
||||
if _, leaked := kindByName["n"]; leaked {
|
||||
t.Errorf("parameter 'n' leaked as a symbol: %v", kindByName)
|
||||
}
|
||||
}
|
||||
|
||||
// Function-local var/const declarations must NOT appear as top-level symbols
|
||||
// (they flood the list and aren't package/module symbols).
|
||||
func TestExtractSymbolsExcludesLocals(t *testing.T) {
|
||||
|
||||
@@ -186,6 +186,9 @@ func TestFirstLineOf(t *testing.T) {
|
||||
{"line1\nline2", 20, "line1"},
|
||||
{"\n\nfoo", 20, "foo"},
|
||||
{"abcdefghij", 5, "abcd…"},
|
||||
// Multibyte: "ααα" is 6 bytes (each α = 2). maxLen-1 = 3 lands inside the
|
||||
// 2nd α; must back off to a rune boundary, never split into invalid UTF-8.
|
||||
{"ααα", 4, "α…"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := firstLineOf(c.input, c.maxLen)
|
||||
|
||||
@@ -583,7 +583,7 @@ func firstLineOf(s string, maxLen int) string {
|
||||
continue
|
||||
}
|
||||
if len(line) > maxLen {
|
||||
return line[:maxLen-1] + "…"
|
||||
return line[:util.RuneBoundary(line, maxLen-1)] + "…"
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
json "github.com/goccy/go-json"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/config"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/internal/util"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/errors"
|
||||
"github.com/lukaszraczylo/mcp-filepuff/pkg/protocol"
|
||||
)
|
||||
@@ -495,9 +495,5 @@ func truncateLine(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
end := max(maxLen-3, 0)
|
||||
for end > 0 && !utf8.RuneStart(s[end]) {
|
||||
end--
|
||||
}
|
||||
return s[:end] + "..."
|
||||
return s[:util.RuneBoundary(s, maxLen-3)] + "..."
|
||||
}
|
||||
|
||||
@@ -150,8 +150,101 @@ func TestASTQueryFormatLocation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestASTQuerySkipsDependencyDirs verifies ast_query excludes node_modules/vendor
|
||||
// by default (they flood results with third-party code) but still searches them
|
||||
// when the caller targets one explicitly via paths (the walk root is never skipped).
|
||||
func TestASTQuerySkipsDependencyDirs(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
deps := map[string]string{
|
||||
"node_modules": "NodeModulesDep",
|
||||
"vendor": "VendorDep",
|
||||
}
|
||||
for dir, fn := range deps {
|
||||
full := filepath.Join(tmpDir, dir)
|
||||
if err := os.MkdirAll(full, 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
src := "package dep\n\nfunc " + fn + "() string { return \"\" }\n"
|
||||
if err := os.WriteFile(filepath.Join(full, "dep.go"), []byte(src), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Default: workspace-root query must skip the dependency dirs.
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Arguments = map[string]interface{}{
|
||||
"pattern": "func $NAME() string",
|
||||
"language": "go",
|
||||
"paths": []interface{}{tmpDir},
|
||||
"format": "location",
|
||||
}
|
||||
res, err := srv.handleASTQuery(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("default query error: %v", err)
|
||||
}
|
||||
// location format prints file:line, so assert on paths.
|
||||
text := res.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(text, "a.go") {
|
||||
t.Errorf("default query should find top-level a.go, got:\n%s", text)
|
||||
}
|
||||
for dir := range deps {
|
||||
if strings.Contains(text, dir+string(filepath.Separator)) {
|
||||
t.Errorf("default query must skip %s, got:\n%s", dir, text)
|
||||
}
|
||||
}
|
||||
|
||||
// Explicit target: querying inside node_modules must still find its code.
|
||||
req2 := mcp.CallToolRequest{}
|
||||
req2.Params.Arguments = map[string]interface{}{
|
||||
"pattern": "func $NAME() string",
|
||||
"language": "go",
|
||||
"paths": []interface{}{filepath.Join(tmpDir, "node_modules")},
|
||||
"format": "location",
|
||||
}
|
||||
res2, err := srv.handleASTQuery(ctx, req2)
|
||||
if err != nil {
|
||||
t.Fatalf("explicit-target query error: %v", err)
|
||||
}
|
||||
text2 := res2.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(text2, "node_modules"+string(filepath.Separator)+"dep.go") {
|
||||
t.Errorf("explicit node_modules target must find node_modules/dep.go, got:\n%s", text2)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Feature 3 (ast_query): pagination cursor ----
|
||||
|
||||
// TestASTQueryFirstPageCursor is a strict regression guard: page 1 must emit a
|
||||
// cursor with an accurate remaining count when more matches exist. Previously
|
||||
// the page-1 walk was capped at max_results, so the total was truncated and the
|
||||
// cursor fired nondeterministically (and with a wrong remaining count).
|
||||
func TestASTQueryFirstPageCursor(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Fixture has 5 functions (Alpha/Beta/Gamma/Delta/Epsilon); cap at 2.
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Arguments = map[string]interface{}{
|
||||
"pattern": "func $NAME() $RET",
|
||||
"language": "go",
|
||||
"paths": []interface{}{tmpDir},
|
||||
"max_results": float64(2),
|
||||
}
|
||||
res, err := srv.handleASTQuery(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("page1 error: %v", err)
|
||||
}
|
||||
text := res.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(text, "[cursor:") {
|
||||
t.Fatalf("page1 with 5 matches capped at 2 must emit a cursor, got:\n%s", text)
|
||||
}
|
||||
// 5 total - 2 shown = 3 remaining.
|
||||
if !strings.Contains(text, "remaining: 3") {
|
||||
t.Errorf("expected accurate 'remaining: 3', got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASTQueryPaginationCursor(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
ctx := context.Background()
|
||||
@@ -292,6 +385,39 @@ func TestFileSearchCursorStaleHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFileSearchFirstPageCursor is a strict regression guard: when the first
|
||||
// page is capped by max_results but more matches exist, the response MUST carry
|
||||
// a cursor so the caller can page. Previously offset==0 emitted "(truncated...)"
|
||||
// with no cursor, silently breaking pagination on the common path.
|
||||
func TestFileSearchFirstPageCursor(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
if srv.searcher == nil {
|
||||
t.Skip("rg not available")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
// Corpus has 5 lines containing "func"; cap at 1 forces truncation on page 1.
|
||||
req := mcp.CallToolRequest{}
|
||||
req.Params.Arguments = map[string]interface{}{
|
||||
"pattern": "func",
|
||||
"paths": []interface{}{tmpDir},
|
||||
"max_results": float64(1),
|
||||
"context_lines": float64(0),
|
||||
}
|
||||
res, err := srv.handleFileSearch(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatalf("file_search error: %v", err)
|
||||
}
|
||||
text := res.Content[0].(mcp.TextContent).Text
|
||||
if !strings.Contains(text, "[cursor:") {
|
||||
t.Errorf("first page truncated at max_results=1 with 5 matches must emit a cursor, got:\n%s", text)
|
||||
}
|
||||
// The dead-end prose must not be used when a cursor is available.
|
||||
if strings.Contains(text, "(truncated, showing subset") {
|
||||
t.Errorf("dead-end truncation prose should be replaced by a cursor footer, got:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileSearchPaginationCursor(t *testing.T) {
|
||||
srv, tmpDir := newFeaturesServer(t)
|
||||
if srv.searcher == nil {
|
||||
|
||||
@@ -109,12 +109,13 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
|
||||
},
|
||||
}
|
||||
|
||||
// Collect limit for early-exit: when paginating we need all results first.
|
||||
collectLimit := p.maxResults
|
||||
if p.offset > 0 {
|
||||
collectLimit = 0
|
||||
}
|
||||
|
||||
// A full walk is required on every page: the cursor's "remaining" count is
|
||||
// derived from the total match count (see buildASTCursorFooter), so the page-1
|
||||
// response can only emit a correct cursor if it has seen every match. Capping
|
||||
// page 1 at max_results made the cursor fire nondeterministically (only when
|
||||
// the last parsed file overshot the cap) with a wrong remaining count. Parse
|
||||
// results are LRU-cached, so re-walking on later pages is mostly file I/O, and
|
||||
// matches below max_results already visit the whole tree regardless.
|
||||
var allResults []query.MatchResult
|
||||
for _, searchPath := range p.paths {
|
||||
if !s.cfg.IsPathAllowed(searchPath) {
|
||||
@@ -131,7 +132,10 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() {
|
||||
if strings.HasPrefix(info.Name(), ".") {
|
||||
// Skip hidden and dependency dirs (third-party code that would
|
||||
// flood results), but never the walk root itself — the caller
|
||||
// explicitly targeted it, e.g. paths:["vendor/foo"].
|
||||
if path != searchPath && (strings.HasPrefix(info.Name(), ".") || isDependencyDir(info.Name())) {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
@@ -159,10 +163,6 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
|
||||
return nil
|
||||
}
|
||||
allResults = append(allResults, matches...)
|
||||
|
||||
if collectLimit > 0 && len(allResults) >= collectLimit {
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if walkErr != nil {
|
||||
@@ -172,6 +172,19 @@ func (s *Server) runASTQueryWalk(ctx context.Context, p *astQueryParams, exts []
|
||||
return allResults
|
||||
}
|
||||
|
||||
// isDependencyDir reports whether name is a well-known third-party dependency
|
||||
// directory that ast_query skips by default. These hold vendored/installed code,
|
||||
// not the caller's source, and parsing them wastes time and floods results with
|
||||
// irrelevant matches. Target one explicitly via paths to query inside it.
|
||||
func isDependencyDir(name string) bool {
|
||||
switch name {
|
||||
case "node_modules", "vendor", "bower_components":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// hasAnySuffix reports whether path ends with any of the given suffixes.
|
||||
func hasAnySuffix(path string, suffixes []string) bool {
|
||||
for _, ext := range suffixes {
|
||||
|
||||
@@ -92,22 +92,21 @@ func (s *Server) handleEdit(ctx context.Context, request mcp.CallToolRequest) (*
|
||||
return mcp.NewToolResultText(fmt.Sprintf("+%d -%d", added, removed)), nil
|
||||
|
||||
case "diff":
|
||||
var output strings.Builder
|
||||
output.WriteString("Diff:\n```diff\n")
|
||||
output.WriteString(result.Diff)
|
||||
output.WriteString("```\n")
|
||||
return mcp.NewToolResultText(output.String()), nil
|
||||
return mcp.NewToolResultText(fencedDiff(result.Diff)), nil
|
||||
|
||||
default:
|
||||
// Fallback: treat unknown values as "diff" for safety.
|
||||
var output strings.Builder
|
||||
output.WriteString("Diff:\n```diff\n")
|
||||
output.WriteString(result.Diff)
|
||||
output.WriteString("```\n")
|
||||
return mcp.NewToolResultText(output.String()), nil
|
||||
return mcp.NewToolResultText(fencedDiff(result.Diff)), nil
|
||||
}
|
||||
}
|
||||
|
||||
// fencedDiff wraps a unified diff in a ```diff code fence. No "Diff:" label is
|
||||
// added — the fence and the diff's own ---/+++ headers already identify it, so
|
||||
// the label would only cost tokens.
|
||||
func fencedDiff(diff string) string {
|
||||
return "```diff\n" + diff + "```\n"
|
||||
}
|
||||
|
||||
// countDiffLines counts added (+) and removed (-) content lines in a unified diff.
|
||||
// Only lines inside hunks are counted (everything after the first "@@" header), so the
|
||||
// "---"/"+++" file headers are skipped structurally — and content whose own text starts
|
||||
|
||||
@@ -59,6 +59,33 @@ func TestEditApplyPreservesBackslashSequencesText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestEditApplyDiffResponseFencedNoLabel verifies response=diff emits the unified
|
||||
// diff in a ```diff fence with no redundant "Diff:" label (the fence and the diff's
|
||||
// own headers already identify it; the label would only cost tokens).
|
||||
func TestEditApplyDiffResponseFencedNoLabel(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
srv := newTestServer(t, tmpDir)
|
||||
f := writeFile(t, tmpDir, "note.txt", "before\n")
|
||||
|
||||
res := callEdit(t, srv, map[string]any{
|
||||
"file": f,
|
||||
"operation": "replace",
|
||||
"selector_text": "before",
|
||||
"new_content": "after",
|
||||
"response": "diff",
|
||||
})
|
||||
if res.IsError {
|
||||
t.Fatalf("edit returned error: %+v", res.Content)
|
||||
}
|
||||
out := res.Content[0].(mcp.TextContent).Text
|
||||
if !strings.HasPrefix(out, "```diff\n") {
|
||||
t.Errorf("diff response should start with the ```diff fence, got:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "Diff:") {
|
||||
t.Errorf("diff response should not carry a redundant 'Diff:' label, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountDiffLinesHunkAware verifies the line counter skips file headers structurally and
|
||||
// counts content lines whose own text begins with + or - (previously dropped as "+++"/"---").
|
||||
func TestCountDiffLinesHunkAware(t *testing.T) {
|
||||
|
||||
@@ -127,13 +127,23 @@ func (s *Server) handleFileSearch(ctx context.Context, request mcp.CallToolReque
|
||||
|
||||
// Apply in-process max_results cap and compute cursor footer.
|
||||
var cursorLine string
|
||||
if maxResults > 0 && len(results.Results) > maxResults {
|
||||
switch {
|
||||
case maxResults > 0 && len(results.Results) > maxResults:
|
||||
// Cursor pages (offset>0) fetch all results, so cap here and emit a cursor.
|
||||
remaining := len(results.Results) - maxResults
|
||||
results.Results = results.Results[:maxResults]
|
||||
results.Truncated = true
|
||||
nextOffset := offset + maxResults
|
||||
nextCursor := cursor.Encode(nextOffset, queryHash)
|
||||
cursorLine = fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining)
|
||||
case offset == 0 && maxResults > 0 && results.Truncated:
|
||||
// First page: ripgrep capped us at exactly maxResults in parseOutput, but
|
||||
// more matches exist. Emit a cursor so the caller can page instead of being
|
||||
// told "truncated" with no way to continue. remaining derives from rg's
|
||||
// total match count (a submatch count — an upper-bound proxy for rows).
|
||||
nextCursor := cursor.Encode(maxResults, queryHash)
|
||||
remaining := max(results.Total-maxResults, 1)
|
||||
cursorLine = fmt.Sprintf("[cursor: %s, remaining: %d]", nextCursor, remaining)
|
||||
}
|
||||
|
||||
s.logger.Info("search completed",
|
||||
|
||||
@@ -96,6 +96,11 @@ const helpASTQuery = "# ast_query — flags and examples\n\n" +
|
||||
"Captured `$VAR` values appear in output but do not constrain which nodes match.\n\n" +
|
||||
"For precise selection, combine the pattern with `name_exact`, `name_matches`, and/or `kind_in` — " +
|
||||
"these ARE applied as filters.\n\n" +
|
||||
"## Scope\n\n" +
|
||||
"The walk skips hidden directories (dot-prefixed) and dependency directories " +
|
||||
"(`node_modules`, `vendor`, `bower_components`) — these hold third-party code that would " +
|
||||
"flood results. To query inside one, name it explicitly in `paths` (e.g. `\"paths\": [\"vendor/foo\"]`); " +
|
||||
"the targeted root is never skipped. Note: unlike file_search, ast_query does not consult .gitignore.\n\n" +
|
||||
"## Examples\n\n" +
|
||||
"```json\n" +
|
||||
"// All Go functions/methods (the 'error' return is NOT enforced — add name_/kind_in to narrow)\n" +
|
||||
|
||||
@@ -193,7 +193,7 @@ func (s *Server) registerTools() {
|
||||
mcp.Description("Output mode: 'full' (default, full file), 'skeleton' (signatures + { ... } stubs, bodies elided), 'symbols_only' (symbol list only, alias for symbols_only=true)."),
|
||||
),
|
||||
mcp.WithArray("strip",
|
||||
mcp.Description("Strip content classes before line-numbering. Values: 'imports' (remove import blocks), 'license' (remove leading license comment), 'block_comments' (remove /* */ and Python triple-quoted strings). Emits [stripped: ...] footer."),
|
||||
mcp.Description("Strip content classes before line-numbering. Values: 'imports' (remove import blocks), 'license' (remove leading license comment), 'block_comments' (remove /* */ and Python triple-quoted strings). Emits [stripped: ...] footer. Note: line numbers are renumbered from 1 over the stripped content, so they no longer match the file's original line numbers."),
|
||||
mcp.WithStringItems(),
|
||||
),
|
||||
mcp.WithNumber("max_lines",
|
||||
@@ -226,6 +226,7 @@ func (s *Server) registerTools() {
|
||||
mcp.NewTool("ast_query",
|
||||
mcp.WithDescription("Search for AST patterns in code files. Use code patterns with $VAR placeholders to match and capture code structures like functions, classes, and types. "+
|
||||
"Matching is keyword/kind-based, not fully structural: argument and return-type constraints in the pattern are not enforced — use name_exact/name_matches/kind_in to filter precisely. "+
|
||||
"Hidden dirs and dependency dirs (node_modules, vendor, bower_components) are skipped unless targeted explicitly via paths. "+
|
||||
"See resource filepuff://help/ast_query for flags and examples."),
|
||||
mcp.WithReadOnlyHintAnnotation(true),
|
||||
mcp.WithString("pattern",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package util
|
||||
|
||||
import "unicode/utf8"
|
||||
|
||||
// RuneBoundary returns the largest index j <= i such that s[:j] is valid UTF-8
|
||||
// (j falls on a rune start, never inside a multibyte sequence). i is clamped to
|
||||
// [0, len(s)]. Callers slicing s[:i] for truncation should slice s[:RuneBoundary(s, i)]
|
||||
// instead so a multibyte character is never split into an invalid encoding.
|
||||
func RuneBoundary(s string, i int) int {
|
||||
if i <= 0 {
|
||||
return 0
|
||||
}
|
||||
if i >= len(s) {
|
||||
return len(s)
|
||||
}
|
||||
for i > 0 && !utf8.RuneStart(s[i]) {
|
||||
i--
|
||||
}
|
||||
return i
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestRuneBoundary(t *testing.T) {
|
||||
// "café" — 'é' is 2 bytes (0xC3 0xA9), occupying indices 3,4. len == 5.
|
||||
const cafe = "café"
|
||||
cases := []struct {
|
||||
name string
|
||||
s string
|
||||
i int
|
||||
want int
|
||||
}{
|
||||
{"negative clamps to zero", "abc", -1, 0},
|
||||
{"zero", "abc", 0, 0},
|
||||
{"beyond length clamps to len", "abc", 99, 3},
|
||||
{"ascii exact boundary", "abcdef", 4, 4},
|
||||
{"inside multibyte backs off", cafe, 4, 3}, // index 4 is the 2nd byte of 'é'
|
||||
{"on multibyte start stays", cafe, 3, 3}, // index 3 is the 1st byte of 'é'
|
||||
{"end of multibyte string", cafe, 5, 5}, // len == 5, clamped path
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := RuneBoundary(c.s, c.i)
|
||||
if got != c.want {
|
||||
t.Fatalf("RuneBoundary(%q, %d) = %d, want %d", c.s, c.i, got, c.want)
|
||||
}
|
||||
// Invariant: the prefix is always valid UTF-8.
|
||||
if !utf8.ValidString(c.s[:got]) {
|
||||
t.Fatalf("RuneBoundary(%q, %d) sliced invalid UTF-8: %q", c.s, c.i, c.s[:got])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user