diff --git a/README.md b/README.md index 17f7e3d..7b4c755 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,39 @@ After downloading or building the binary, configure it in Claude Code: See the [Claude Code MCP documentation](https://code.claude.com/docs/en/mcp) for more details. +### Recommended Claude Code Configuration + +#### Selective Tool Deferral + +For optimal performance, keep the most frequently used tools loaded at startup and defer the rest. This follows [Anthropic's recommended practice](https://www.anthropic.com/engineering/advanced-tool-use) of loading 3-5 high-use tools immediately: + +```json +{ + "mcpServers": { + "filepuff": { + "command": "mcp-filepuff", + "args": ["-workspace", "."], + "alwaysAllow": ["file_read", "file_search", "edit_apply", "edit_preview"] + } + } +} +``` + +This keeps `file_read`, `file_search`, `edit_apply`, and `edit_preview` immediately available while deferring less frequently used tools (`ping`, `ast_query`, `symbol_at`, `find_definition`, `find_references`). + +#### System Prompt Guidance + +Add the following to your `CLAUDE.md` to help Claude understand the available tool categories: + +```markdown +You have access to filepuff MCP tools providing: +- File reading with AST symbol summaries (file_read) +- Fast regex search powered by ripgrep (file_search) +- Structural code pattern matching across 9+ languages (ast_query) +- LSP-powered go-to-definition, find-references, and symbol info (find_definition, find_references, symbol_at) +- AST-aware file editing with syntax validation and preview (edit_preview, edit_apply) +``` + ## Usage ### Running the Server (Standalone) diff --git a/docs/API.md b/docs/API.md index 8a35548..0348bad 100644 --- a/docs/API.md +++ b/docs/API.md @@ -47,9 +47,10 @@ Health check - returns pong to verify the server is running {"tool": "ping"} ``` +**Returns:** `"pong"` text string. + **Notes:** -- Returns: "pong" - Use to verify server connectivity --- @@ -84,6 +85,8 @@ Search for text patterns in files using ripgrep. Supports regex patterns, file t {"pattern": "TODO", "ignore_case": true, "context_lines": 3} ``` +**Returns:** Results grouped by file with match context. Format: `"Found N matches in M files:"` followed by file sections, each with matching lines prefixed by `"L{line}│"` and context lines prefixed by `" │"`. + **Notes:** - Requires ripgrep (rg) to be installed @@ -128,6 +131,8 @@ Read a file's contents with optional line range and AST symbol summary {"path": "large_file.go", "max_lines": 100} ``` +**Returns:** File content with numbered lines (format: `" 12│ line text"`). When `include_ast=true`: prepends symbol summary (`"**file.go** (N lines, go)\nSymbols:\n func Name L12\n struct Config L45"`). When `symbols_only=true`: returns only the symbol summary (~95% fewer tokens). When `max_lines` is set: truncates output with `"[... N more lines omitted]"` notice. + **Notes:** - symbols_only mode reduces token usage by ~90-98% @@ -170,6 +175,8 @@ Search for AST patterns in code files. Use code patterns with $VAR placeholders {"pattern": "function $NAME($PROPS) { $$$BODY }", "language": "javascript", "name_matches": "^[A-Z]"} ``` +**Returns:** `"Found N match(es):"` followed by entries in format `"**file:line** (node_type)"` with code blocks and captured variables (`$NAME=value`). Returns `"No matches found."` when no results. + **Notes:** - $NAME captures identifiers @@ -201,6 +208,8 @@ Get information about the symbol at a specific position in a file. Returns type, {"file": "server.go", "line": 25, "column": 10} ``` +**Returns:** `"**Symbol Information**"` followed by hover/type information from LSP, or `"**Symbol Information** (AST fallback)"` with node type and text when LSP unavailable. Returns `"No symbol information available at this position."` when nothing is found. + **Notes:** - Requires LSP server for full type information @@ -228,10 +237,11 @@ Find the definition of the symbol at a specific position. Uses LSP to locate whe {"file": "server.go", "line": 42, "column": 15} ``` +**Returns:** `"Found N definition(s):"` with entries showing `"**file:line:column**"` and a 3-line code preview with the target line marked by `">"`. Returns `"No definition found."` when the symbol has no definition. + **Notes:** - Requires language server for the file type -- Returns file path, line, and column of definition - Shows code preview at definition location --- @@ -261,6 +271,8 @@ Find all references to the symbol at a specific position. Uses LSP to locate all {"file": "server.go", "line": 42, "column": 15, "include_declaration": false} ``` +**Returns:** `"Found N reference(s):"` grouped by file, each showing `"**file** (count)"` with locations as `"L{line}:{column}"`. Returns `"No references found."` when no usages exist. + **Notes:** - Requires language server for the file type @@ -305,10 +317,10 @@ Preview an edit without applying it. Uses AST-aware editing for code files (Go, {"file": "package.json", "operation": "replace", "selector_pattern": "\\"version\\":\\\\s*\\"[^\\"]+\\"", "new_content": "\\"version\\": \\"2.0.0\\""} ``` +**Returns:** `"**Edit Preview**"` followed by a unified diff showing proposed changes. Does not modify the file. For code files: uses AST-aware mode with syntax validation. For other files: uses text-based mode. + **Notes:** -- Returns a diff showing proposed changes -- Does not modify the file - Use to validate changes before applying --- @@ -344,9 +356,10 @@ Apply an edit to a file. Uses AST-aware editing for code files (Go, TypeScript, {"file": "config.yaml", "operation": "replace", "selector_line": 5, "selector_line_end": 10, "new_content": "database:\\n host: production.db.example.com\\n port: 5432"} ``` +**Returns:** `"**Edit Applied Successfully**"` followed by a unified diff of the changes made. For code files, validates syntax before writing — returns an error if the edit would produce invalid syntax. + **Notes:** -- For code files: validates syntax before and after edit - Preserves file permissions - Uses atomic writes for safety - File locking prevents concurrent edits diff --git a/internal/edit/edit.go b/internal/edit/edit.go index 571b2ff..23a4e64 100644 --- a/internal/edit/edit.go +++ b/internal/edit/edit.go @@ -172,11 +172,9 @@ func (e *Engine) performASTEdit(ctx context.Context, edit *ASTEdit, apply bool) diff := e.generateDiff(string(content), string(newContent), edit.File) result := &EditResult{ - Success: true, - Diff: diff, - OriginalContent: string(content), - NewContent: string(newContent), - Applied: false, + Success: true, + Diff: diff, + Applied: false, } // Apply changes if requested @@ -231,11 +229,9 @@ func (e *Engine) performTextEdit(_ context.Context, edit *ASTEdit, apply bool) ( diff := e.generateDiff(string(content), string(newContent), edit.File) result := &EditResult{ - Success: true, - Diff: diff, - OriginalContent: string(content), - NewContent: string(newContent), - Applied: false, + Success: true, + Diff: diff, + Applied: false, } // Apply changes if requested @@ -568,14 +564,22 @@ func indentContent(content string, indent string) string { return strings.Join(lines, "\n") } +// diffLine represents a single line in the diff with its type and content. +type diffLine struct { + op diffmatchpatch.Operation + text string // line content without trailing newline + oldN int // 1-based line number in original (0 if insert) + newN int // 1-based line number in modified (0 if delete) +} + // generateDiff creates a unified diff between original and modified content. -// Uses line-level Myers diff algorithm for accurate and readable diffs. +// Uses line-level Myers diff algorithm and outputs a proper unified diff +// with context lines (3 before/after each change, merging close hunks). func (e *Engine) generateDiff(original, modified, filename string) string { dmp := e.dmp // Use line-level diffing: encode each line as a single character, // diff the encoded strings, then decode back to real lines. - // This prevents character-level diffs from splitting lines incorrectly. chars1, chars2, lineArray := dmp.DiffLinesToChars(original, modified) diffs := dmp.DiffMain(chars1, chars2, false) diffs = dmp.DiffCharsToLines(diffs, lineArray) @@ -583,30 +587,117 @@ func (e *Engine) generateDiff(original, modified, filename string) string { // Cleanup for readability diffs = dmp.DiffCleanupSemantic(diffs) - // Convert to unified diff format + // Flatten diffs into individual lines with line numbers + var lines []diffLine + oldLine := 1 + newLine := 1 + for _, d := range diffs { + rawLines := strings.SplitAfter(d.Text, "\n") + for _, raw := range rawLines { + if raw == "" { + continue + } + text := strings.TrimSuffix(raw, "\n") + switch d.Type { + case diffmatchpatch.DiffEqual: + lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine, newN: newLine}) + oldLine++ + newLine++ + case diffmatchpatch.DiffDelete: + lines = append(lines, diffLine{op: d.Type, text: text, oldN: oldLine}) + oldLine++ + case diffmatchpatch.DiffInsert: + lines = append(lines, diffLine{op: d.Type, text: text, newN: newLine}) + newLine++ + } + } + } + + // Identify indices of changed lines + const contextSize = 3 + var changedIndices []int + for i, l := range lines { + if l.op != diffmatchpatch.DiffEqual { + changedIndices = append(changedIndices, i) + } + } + + if len(changedIndices) == 0 { + return "" // no changes + } + + // Build inclusion ranges: for each changed line, include contextSize lines before/after. + // Merge overlapping or adjacent ranges (gap <= 2*contextSize = 6 context lines). + type indexRange struct{ start, end int } // inclusive + var ranges []indexRange + for _, ci := range changedIndices { + rStart := ci - contextSize + if rStart < 0 { + rStart = 0 + } + rEnd := ci + contextSize + if rEnd >= len(lines) { + rEnd = len(lines) - 1 + } + if len(ranges) > 0 && rStart <= ranges[len(ranges)-1].end+1 { + // Merge with previous range + ranges[len(ranges)-1].end = rEnd + } else { + ranges = append(ranges, indexRange{rStart, rEnd}) + } + } + + // Emit unified diff var buf bytes.Buffer buf.WriteString(fmt.Sprintf("--- %s\n", filename)) buf.WriteString(fmt.Sprintf("+++ %s\n", filename)) - for _, diff := range diffs { - // SplitAfter preserves the trailing \n on each line, so we can - // distinguish real lines from a trailing empty split artifact. - lines := strings.SplitAfter(diff.Text, "\n") - for _, line := range lines { - if line == "" { - continue - } - - // Remove trailing newline for display — we add our own. - cleanLine := strings.TrimSuffix(line, "\n") - - switch diff.Type { - case diffmatchpatch.DiffDelete: - buf.WriteString(fmt.Sprintf("-%s\n", cleanLine)) - case diffmatchpatch.DiffInsert: - buf.WriteString(fmt.Sprintf("+%s\n", cleanLine)) + for _, r := range ranges { + // Determine hunk header line numbers + var oldStart, oldCount, newStart, newCount int + for i := r.start; i <= r.end; i++ { + l := lines[i] + switch l.op { case diffmatchpatch.DiffEqual: - buf.WriteString(fmt.Sprintf(" %s\n", cleanLine)) + if oldCount == 0 { + oldStart = l.oldN + } + if newCount == 0 { + newStart = l.newN + } + oldCount++ + newCount++ + case diffmatchpatch.DiffDelete: + if oldCount == 0 { + oldStart = l.oldN + } + if newCount == 0 { + // Set newStart from context or next available + newStart = l.oldN // approximate + } + oldCount++ + case diffmatchpatch.DiffInsert: + if newCount == 0 { + newStart = l.newN + } + if oldCount == 0 { + oldStart = l.newN // approximate + } + newCount++ + } + } + + buf.WriteString(fmt.Sprintf("@@ -%d,%d +%d,%d @@\n", oldStart, oldCount, newStart, newCount)) + + for i := r.start; i <= r.end; i++ { + l := lines[i] + switch l.op { + case diffmatchpatch.DiffEqual: + buf.WriteString(fmt.Sprintf(" %s\n", l.text)) + case diffmatchpatch.DiffDelete: + buf.WriteString(fmt.Sprintf("-%s\n", l.text)) + case diffmatchpatch.DiffInsert: + buf.WriteString(fmt.Sprintf("+%s\n", l.text)) } } } diff --git a/internal/lsp/manager.go b/internal/lsp/manager.go index 5494553..50dbdcc 100644 --- a/internal/lsp/manager.go +++ b/internal/lsp/manager.go @@ -85,6 +85,9 @@ var DefaultServerConfigs = map[protocol.Language]ServerConfig{ protocol.LangCpp: { Command: []string{"clangd"}, }, + protocol.LangRust: { + Command: []string{"rust-analyzer"}, + }, } // AllowedLSPBinaries is a whitelist of allowed LSP server binary names. @@ -602,6 +605,8 @@ func languageToLSPID(lang protocol.Language) string { return "c" case protocol.LangCpp: return "cpp" + case protocol.LangRust: + return "rust" default: return string(lang) } diff --git a/internal/parser/docextract.go b/internal/parser/docextract.go index 1a0e098..7cde3c3 100644 --- a/internal/parser/docextract.go +++ b/internal/parser/docextract.go @@ -48,6 +48,8 @@ func ExtractDocComment(n *sitter.Node, content []byte, lang protocol.Language) * return extractCDocComment(n, content) case protocol.LangElixir: return extractElixirDocComment(n, content) + case protocol.LangRust: + return extractRustDocComment(n, content) default: return nil } @@ -551,6 +553,64 @@ func cleanPythonDocstring(doc string) string { return strings.TrimSpace(doc) } +// extractRustDocComment extracts Rust documentation comments (/// style). +func extractRustDocComment(n *sitter.Node, content []byte) *DocComment { + comments := collectPrecedingComments(n, content, []string{"line_comment"}) + if len(comments) == 0 { + return nil + } + + // Filter for /// doc comments only + var docComments []*sitter.Node + for _, c := range comments { + text := GetNodeText(c, content) + trimmed := strings.TrimSpace(text) + if strings.HasPrefix(trimmed, "///") { + docComments = append(docComments, c) + } + } + + if len(docComments) == 0 { + return nil + } + + var parts []string + var raw []string + startLine := -1 + endLine := -1 + + for _, c := range docComments { + text := GetNodeText(c, content) + raw = append(raw, text) + + if startLine == -1 { + startLine = int(c.StartPoint().Row) + 1 + } + endLine = int(c.EndPoint().Row) + 1 + + // Clean /// prefix + cleaned := strings.TrimSpace(text) + cleaned = strings.TrimPrefix(cleaned, "///") + if len(cleaned) > 0 && cleaned[0] == ' ' { + cleaned = cleaned[1:] + } + parts = append(parts, cleaned) + } + + if len(parts) == 0 { + return nil + } + + return &DocComment{ + Text: strings.Join(parts, "\n"), + Raw: strings.Join(raw, "\n"), + Style: CommentStyleDoxygen, + Tags: nil, + StartLine: startLine, + EndLine: endLine, + } +} + // extractElixirDocComment extracts Elixir documentation from @doc and @moduledoc attributes. // Elixir uses module attributes like @doc and @moduledoc for documentation. func extractElixirDocComment(n *sitter.Node, content []byte) *DocComment { diff --git a/internal/parser/parser.go b/internal/parser/parser.go index 359abb8..a8f76e4 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -18,6 +18,7 @@ import ( "github.com/smacker/go-tree-sitter/html" "github.com/smacker/go-tree-sitter/javascript" "github.com/smacker/go-tree-sitter/python" + "github.com/smacker/go-tree-sitter/rust" "github.com/smacker/go-tree-sitter/typescript/typescript" "github.com/lukaszraczylo/mcp-filepuff/pkg/errors" @@ -117,10 +118,12 @@ func getLanguage(lang protocol.Language) (*sitter.Language, error) { return html.GetLanguage(), nil case protocol.LangElixir: return elixir.GetLanguage(), nil + case protocol.LangRust: + return rust.GetLanguage(), nil default: return nil, errors.New(errors.ErrInvalidLanguage, fmt.Sprintf("language %s is not supported", lang)). WithContext("language", string(lang)). - WithRemediation("Supported languages: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue, Elixir") + WithRemediation("Supported languages: Go, TypeScript, JavaScript, Python, C, C++, HTML, Vue, Elixir, Rust") } } diff --git a/internal/parser/symbols.go b/internal/parser/symbols.go index 6df4d39..e97516c 100644 --- a/internal/parser/symbols.go +++ b/internal/parser/symbols.go @@ -27,6 +27,8 @@ func ExtractSymbols(tree *sitter.Tree, content []byte, lang protocol.Language, f return extractCSymbols(root, content, filename) case protocol.LangElixir: return extractElixirSymbols(root, content, filename) + case protocol.LangRust: + return extractRustSymbols(root, content, filename) default: return nil } @@ -714,6 +716,133 @@ func extractElixirProtocol(n *sitter.Node, content []byte, filename string) *pro } } +// extractRustSymbols extracts symbols from Rust code. +func extractRustSymbols(root *sitter.Node, content []byte, filename string) []protocol.Symbol { + var symbols []protocol.Symbol + + WalkTree(root, func(n *sitter.Node) bool { + var symbol *protocol.Symbol + + switch n.Type() { + case "function_item": + nameNode := n.ChildByFieldName("name") + if nameNode != nil { + symbol = &protocol.Symbol{ + Name: GetNodeText(nameNode, content), + Kind: protocol.SymbolFunction, + Location: NodeLocation(n, filename), + } + } + case "struct_item": + nameNode := n.ChildByFieldName("name") + if nameNode != nil { + symbol = &protocol.Symbol{ + Name: GetNodeText(nameNode, content), + Kind: protocol.SymbolStruct, + Location: NodeLocation(n, filename), + } + } + case "enum_item": + nameNode := n.ChildByFieldName("name") + if nameNode != nil { + symbol = &protocol.Symbol{ + Name: GetNodeText(nameNode, content), + Kind: protocol.SymbolEnum, + Location: NodeLocation(n, filename), + } + } + case "trait_item": + nameNode := n.ChildByFieldName("name") + if nameNode != nil { + symbol = &protocol.Symbol{ + Name: GetNodeText(nameNode, content), + Kind: protocol.SymbolTrait, + Location: NodeLocation(n, filename), + } + } + case "impl_item": + symbol = extractRustImpl(n, content, filename) + case "type_item": + nameNode := n.ChildByFieldName("name") + if nameNode != nil { + symbol = &protocol.Symbol{ + Name: GetNodeText(nameNode, content), + Kind: protocol.SymbolType, + Location: NodeLocation(n, filename), + } + } + case "const_item": + nameNode := n.ChildByFieldName("name") + if nameNode != nil { + symbol = &protocol.Symbol{ + Name: GetNodeText(nameNode, content), + Kind: protocol.SymbolConstant, + Location: NodeLocation(n, filename), + } + } + case "static_item": + nameNode := n.ChildByFieldName("name") + if nameNode != nil { + symbol = &protocol.Symbol{ + Name: GetNodeText(nameNode, content), + Kind: protocol.SymbolVariable, + Location: NodeLocation(n, filename), + } + } + case "macro_definition": + nameNode := n.ChildByFieldName("name") + if nameNode != nil { + symbol = &protocol.Symbol{ + Name: GetNodeText(nameNode, content) + " (macro)", + Kind: protocol.SymbolFunction, + Location: NodeLocation(n, filename), + } + } + case "mod_item": + nameNode := n.ChildByFieldName("name") + if nameNode != nil { + symbol = &protocol.Symbol{ + Name: GetNodeText(nameNode, content), + Kind: protocol.SymbolModule, + Location: NodeLocation(n, filename), + } + } + } + + if symbol != nil { + if doc := ExtractDocComment(n, content, protocol.LangRust); doc != nil { + symbol.Doc = FormatDocComment(doc) + } + symbols = append(symbols, *symbol) + } + + return true + }) + + return symbols +} + +// extractRustImpl extracts an impl block symbol from Rust code. +func extractRustImpl(n *sitter.Node, content []byte, filename string) *protocol.Symbol { + typeNode := n.ChildByFieldName("type") + traitNode := n.ChildByFieldName("trait") + + var name string + if traitNode != nil && typeNode != nil { + name = "impl " + GetNodeText(traitNode, content) + " for " + GetNodeText(typeNode, content) + } else if typeNode != nil { + name = "impl " + GetNodeText(typeNode, content) + } else { + return nil + } + + return &protocol.Symbol{ + Name: name, + Kind: protocol.SymbolType, + Location: NodeLocation(n, filename), + } +} + // extractElixirImpl extracts a protocol implementation. func extractElixirImpl(n *sitter.Node, content []byte, filename string) *protocol.Symbol { // defimpl Protocol, for: Type do ... end diff --git a/internal/server/handlers_ast.go b/internal/server/handlers_ast.go index 92f9f33..f9feb03 100644 --- a/internal/server/handlers_ast.go +++ b/internal/server/handlers_ast.go @@ -56,7 +56,7 @@ func (s *Server) handleASTQuery(ctx context.Context, request mcp.CallToolRequest // Find files to search based on language exts := languageToExtensions(language) if len(exts) == 0 { - return mcp.NewToolResultError(fmt.Sprintf("unsupported language: %s (supported: go, typescript, javascript, python, c, cpp, html, vue, elixir)", language)), nil + return mcp.NewToolResultError(fmt.Sprintf("unsupported language: %s (supported: go, typescript, javascript, python, c, cpp, html, vue, elixir, rust)", language)), nil } var allResults []query.MatchResult @@ -205,6 +205,10 @@ func symbolKindIcon(kind protocol.SymbolKind) string { return "mod" case protocol.SymbolPackage: return "pkg" + case protocol.SymbolEnum: + return "enum" + case protocol.SymbolTrait: + return "trait" default: return "sym" } @@ -231,6 +235,8 @@ func languageToExtensions(language string) []string { return []string{".vue"} case "elixir": return []string{".ex", ".exs"} + case "rust": + return []string{".rs"} default: return nil } diff --git a/internal/server/server.go b/internal/server/server.go index df3852e..82ff009 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -89,7 +89,8 @@ func (s *Server) registerTools() { // Register ping tool for health checks s.mcp.AddTool( mcp.NewTool("ping", - mcp.WithDescription("Health check - returns pong to verify the server is running"), + mcp.WithDescription("Health check - returns pong to verify the server is running.\n\n"+ + "Returns: \"pong\" text string."), mcp.WithReadOnlyHintAnnotation(true), ), s.handlePing, @@ -99,7 +100,10 @@ func (s *Server) registerTools() { if s.searcher != nil { s.mcp.AddTool( mcp.NewTool("file_search", - mcp.WithDescription("Search for text patterns in files using ripgrep. Supports regex patterns, file type filtering, and context lines."), + mcp.WithDescription("Search for text patterns in files using ripgrep. Supports regex patterns, file type filtering, and context lines.\n\n"+ + "Returns: Results grouped by file with match context. Format: \"Found N matches in M files:\" followed by file sections, "+ + "each with matching lines prefixed by \"L{line}│\" and context lines prefixed by \" │\".\n\n"+ + "Example: {\"pattern\": \"func.*Error\", \"file_types\": [\"go\"], \"max_results\": 20}"), mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("pattern", mcp.Required(), @@ -133,7 +137,16 @@ func (s *Server) registerTools() { // Register file_read tool s.mcp.AddTool( mcp.NewTool("file_read", - mcp.WithDescription("Read a file's contents with optional line range and AST symbol summary"), + mcp.WithDescription("Read a file's contents with optional line range and AST symbol summary.\n\n"+ + "Returns: File content with numbered lines (format: \" 12│ line text\"). "+ + "When include_ast=true: prepends symbol summary (\"**file.go** (N lines, go)\\nSymbols:\\n func Name L12\\n struct Config L45\"). "+ + "When symbols_only=true: returns only the symbol summary (~95% fewer tokens). "+ + "When max_lines is set: truncates output with \"[... N more lines omitted]\" notice.\n\n"+ + "Examples:\n"+ + " Full file: {\"path\": \"main.go\"}\n"+ + " With AST: {\"path\": \"main.go\", \"include_ast\": true}\n"+ + " Symbols only: {\"path\": \"main.go\", \"include_ast\": true, \"symbols_only\": true}\n"+ + " Line range: {\"path\": \"main.go\", \"line_start\": 10, \"line_end\": 50}"), mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("path", mcp.Required(), @@ -161,7 +174,13 @@ func (s *Server) registerTools() { // Register ast_query tool s.mcp.AddTool( 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."), + 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.\n\n"+ + "Returns: \"Found N match(es):\" followed by entries in format \"**file:line** (node_type)\" with code blocks "+ + "and captured variables ($NAME=value). Returns \"No matches found.\" when no results.\n\n"+ + "Examples:\n"+ + " Go error funcs: {\"pattern\": \"func $NAME($$$ARGS) error\", \"language\": \"go\"}\n"+ + " Python classes: {\"pattern\": \"class $NAME: $$$BODY\", \"language\": \"python\"}\n"+ + " Named function: {\"pattern\": \"func $NAME($$$ARGS)\", \"language\": \"go\", \"name_exact\": \"NewServer\"}"), mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("pattern", mcp.Required(), @@ -169,7 +188,7 @@ func (s *Server) registerTools() { ), mcp.WithString("language", mcp.Required(), - mcp.Description("Target language: go, typescript, javascript, python, c, cpp, html, vue, elixir"), + mcp.Description("Target language: go, typescript, javascript, python, c, cpp, html, vue, elixir, rust"), ), mcp.WithArray("paths", mcp.Description("Paths to search in (defaults to workspace root)"), @@ -197,7 +216,10 @@ func (s *Server) registerTools() { // Register symbol_at tool s.mcp.AddTool( mcp.NewTool("symbol_at", - mcp.WithDescription("Get information about the symbol at a specific position in a file. Returns type, documentation, and definition location using LSP when available."), + mcp.WithDescription("Get information about the symbol at a specific position in a file. Returns type, documentation, and definition location using LSP when available.\n\n"+ + "Returns: \"**Symbol Information**\" followed by hover/type information from LSP, or \"**Symbol Information** (AST fallback)\" "+ + "with node type and text when LSP unavailable. Returns \"No symbol information available at this position.\" when nothing is found.\n\n"+ + "Example: {\"file\": \"server.go\", \"line\": 45, \"column\": 6}"), mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("file", mcp.Required(), @@ -218,7 +240,10 @@ func (s *Server) registerTools() { // Register find_definition tool s.mcp.AddTool( mcp.NewTool("find_definition", - mcp.WithDescription("Find the definition of the symbol at a specific position. Uses LSP to locate where a function, variable, type, etc. is defined."), + mcp.WithDescription("Find the definition of the symbol at a specific position. Uses LSP to locate where a function, variable, type, etc. is defined.\n\n"+ + "Returns: \"Found N definition(s):\" with entries showing \"**file:line:column**\" and a 3-line code preview "+ + "with the target line marked by \">\". Returns \"No definition found.\" when the symbol has no definition.\n\n"+ + "Example: {\"file\": \"handler.go\", \"line\": 23, \"column\": 10}"), mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("file", mcp.Required(), @@ -239,7 +264,10 @@ func (s *Server) registerTools() { // Register find_references tool s.mcp.AddTool( mcp.NewTool("find_references", - mcp.WithDescription("Find all references to the symbol at a specific position. Uses LSP to locate all usages of a function, variable, type, etc."), + mcp.WithDescription("Find all references to the symbol at a specific position. Uses LSP to locate all usages of a function, variable, type, etc.\n\n"+ + "Returns: \"Found N reference(s):\" grouped by file, each showing \"**file** (count)\" with locations as "+ + "\"L{line}:{column}\". Returns \"No references found.\" when no usages exist.\n\n"+ + "Example: {\"file\": \"types.go\", \"line\": 5, \"column\": 6}"), mcp.WithReadOnlyHintAnnotation(true), mcp.WithString("file", mcp.Required(), @@ -264,7 +292,13 @@ func (s *Server) registerTools() { // Register edit tools s.mcp.AddTool( mcp.NewTool("edit_preview", - mcp.WithDescription("Preview an edit without applying it. Uses AST-aware editing for code files (Go, TypeScript, JavaScript, Python, C, C++), and text-based editing for other files (Markdown, JSON, YAML, config files, etc.)."), + mcp.WithDescription("Preview an edit without applying it. Uses AST-aware editing for code files (Go, TypeScript, JavaScript, Python, C, C++, Rust), and text-based editing for other files (Markdown, JSON, YAML, config files, etc.).\n\n"+ + "Returns: \"**Edit Preview**\" followed by a unified diff showing proposed changes. Does not modify the file. "+ + "For code files: uses AST-aware mode with syntax validation. For other files: uses text-based mode.\n\n"+ + "Examples:\n"+ + " AST mode: {\"file\": \"main.go\", \"operation\": \"replace\", \"selector_kind\": \"function_declaration\", \"selector_name\": \"Hello\", \"new_content\": \"func Hello() {\\n\\treturn\\n}\"}\n"+ + " Text mode: {\"file\": \"README.md\", \"operation\": \"replace\", \"selector_text\": \"## Old Header\", \"new_content\": \"## New Header\"}\n"+ + " Line range: {\"file\": \"config.yaml\", \"operation\": \"replace\", \"selector_line\": 5, \"selector_line_end\": 10, \"new_content\": \"key: value\"}"), mcp.WithString("file", mcp.Required(), mcp.Description("Path to the file to edit"), @@ -306,7 +340,13 @@ func (s *Server) registerTools() { s.mcp.AddTool( mcp.NewTool("edit_apply", - mcp.WithDescription("Apply an edit to a file. Uses AST-aware editing for code files (Go, TypeScript, JavaScript, Python, C, C++) with syntax validation, and text-based editing for other files (Markdown, JSON, YAML, config files, etc.)."), + mcp.WithDescription("Apply an edit to a file. Uses AST-aware editing for code files (Go, TypeScript, JavaScript, Python, C, C++, Rust) with syntax validation, and text-based editing for other files (Markdown, JSON, YAML, config files, etc.).\n\n"+ + "Returns: \"**Edit Applied Successfully**\" followed by a unified diff of the changes made. "+ + "For code files, validates syntax before writing — returns an error if the edit would produce invalid syntax.\n\n"+ + "Examples:\n"+ + " AST mode: {\"file\": \"main.go\", \"operation\": \"replace\", \"selector_kind\": \"function_declaration\", \"selector_name\": \"Hello\", \"new_content\": \"func Hello() {\\n\\treturn\\n}\"}\n"+ + " Text mode: {\"file\": \"README.md\", \"operation\": \"replace\", \"selector_text\": \"## Old Header\", \"new_content\": \"## New Header\"}\n"+ + " Line range: {\"file\": \"config.yaml\", \"operation\": \"replace\", \"selector_line\": 5, \"selector_line_end\": 10, \"new_content\": \"key: value\"}"), mcp.WithString("file", mcp.Required(), mcp.Description("Path to the file to edit"), diff --git a/pkg/protocol/types.go b/pkg/protocol/types.go index 7ff142a..6a536b3 100644 --- a/pkg/protocol/types.go +++ b/pkg/protocol/types.go @@ -32,6 +32,8 @@ const ( SymbolProperty SymbolKind = "property" SymbolModule SymbolKind = "module" SymbolPackage SymbolKind = "package" + SymbolEnum SymbolKind = "enum" + SymbolTrait SymbolKind = "trait" ) // Symbol represents a code symbol (function, class, variable, etc.). @@ -63,6 +65,7 @@ const ( LangJSON Language = "json" LangYAML Language = "yaml" LangElixir Language = "elixir" + LangRust Language = "rust" LangUnknown Language = "unknown" ) @@ -92,6 +95,8 @@ func DetectLanguage(filename string) Language { return LangYAML case ".ex", ".exs": return LangElixir + case ".rs": + return LangRust default: return LangUnknown } diff --git a/testdata/rust/valid.rs b/testdata/rust/valid.rs new file mode 100644 index 0000000..ecd008e --- /dev/null +++ b/testdata/rust/valid.rs @@ -0,0 +1,65 @@ +/// A simple point in 2D space. +struct Point { + x: f64, + y: f64, +} + +/// Represents different shapes. +enum Shape { + Circle(f64), + Rectangle(f64, f64), + Triangle(f64, f64, f64), +} + +/// A trait for objects that can be drawn. +trait Drawable { + fn draw(&self); + fn area(&self) -> f64; +} + +impl Drawable for Shape { + fn draw(&self) { + println!("Drawing shape"); + } + + fn area(&self) -> f64 { + 0.0 + } +} + +impl Point { + /// Creates a new point. + fn new(x: f64, y: f64) -> Self { + Point { x, y } + } + + fn distance(&self, other: &Point) -> f64 { + ((self.x - other.x).powi(2) + (self.y - other.y).powi(2)).sqrt() + } +} + +/// A type alias for a list of points. +type PointList = Vec; + +/// The maximum number of points allowed. +const MAX_POINTS: usize = 1000; + +static ORIGIN: Point = Point { x: 0.0, y: 0.0 }; + +/// A helper macro for creating points. +macro_rules! point { + ($x:expr, $y:expr) => { + Point::new($x, $y) + }; +} + +mod geometry { + pub fn hello() { + println!("Hello from geometry"); + } +} + +fn main() { + let p = point!(1.0, 2.0); + println!("Point: ({}, {})", p.x, p.y); +}