Skip to main content

hanzo_mcp/tools/
code_tool.rs

1/// Unified code semantics tool (HIP-0300)
2///
3/// Handles semantic code operations:
4/// - parse: Parse source to AST
5/// - serialize: AST to text (round-trips parse→serialize)
6/// - symbols: List symbols in file
7/// - outline: Symbols with imports/exports
8/// - definition: Go to definition
9/// - references: Find all references
10/// - search_symbol: Find symbols across project
11/// - transform: Codemod → Patch
12/// - summarize: Compress to summary
13/// - metrics: Count files/lines by extension
14/// - exports: Extract public exports
15/// - types: Find type definitions
16/// - hierarchy: Build class inheritance tree
17/// - rename: Rename symbols across files
18/// - grep_replace: Pattern replacement across files
19
20use anyhow::{anyhow, Result};
21use regex::Regex;
22use serde::{Deserialize, Serialize};
23use serde_json::{json, Value};
24use std::collections::HashMap;
25use std::path::Path;
26use tree_sitter::{Language, Node, Parser};
27
28const SKIP_DIRS: &[&str] = &[".git", "node_modules", "target", "dist", "__pycache__", ".venv", "venv"];
29
30/// Actions for the code tool
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
32#[serde(rename_all = "snake_case")]
33pub enum CodeAction {
34    Parse,
35    Serialize,
36    Symbols,
37    Outline,
38    Definition,
39    References,
40    SearchSymbol,
41    Transform,
42    Summarize,
43    Metrics,
44    Exports,
45    Types,
46    Hierarchy,
47    Rename,
48    GrepReplace,
49    Help,
50}
51
52impl Default for CodeAction {
53    fn default() -> Self {
54        Self::Help
55    }
56}
57
58impl std::str::FromStr for CodeAction {
59    type Err = anyhow::Error;
60
61    fn from_str(s: &str) -> Result<Self> {
62        match s.to_lowercase().as_str() {
63            "parse" | "ast" => Ok(Self::Parse),
64            "serialize" => Ok(Self::Serialize),
65            "symbols" => Ok(Self::Symbols),
66            "outline" => Ok(Self::Outline),
67            "definition" | "goto" => Ok(Self::Definition),
68            "references" | "refs" => Ok(Self::References),
69            "search_symbol" | "search" => Ok(Self::SearchSymbol),
70            "transform" | "codemod" => Ok(Self::Transform),
71            "summarize" | "summary" => Ok(Self::Summarize),
72            "metrics" => Ok(Self::Metrics),
73            "exports" => Ok(Self::Exports),
74            "types" => Ok(Self::Types),
75            "hierarchy" => Ok(Self::Hierarchy),
76            "rename" => Ok(Self::Rename),
77            "grep_replace" => Ok(Self::GrepReplace),
78            "help" | "" => Ok(Self::Help),
79            _ => Err(anyhow!("Unknown action: {}", s)),
80        }
81    }
82}
83
84/// Arguments for code tool
85#[derive(Debug, Clone, Default, Serialize, Deserialize)]
86pub struct CodeToolArgs {
87    pub action: Option<String>,
88    pub uri: Option<String>,
89    pub path: Option<String>,
90    pub symbol: Option<String>,
91    pub language: Option<String>,
92    pub query: Option<String>,
93    pub spec: Option<String>,
94    pub text: Option<String>,
95    pub pattern: Option<String>,
96    pub new_name: Option<String>,
97    pub replacement: Option<String>,
98    pub max_results: Option<usize>,
99    pub scope: Option<String>,
100    pub ast: Option<Value>,
101}
102
103/// Tool definition for MCP registration
104pub struct CodeToolDefinition;
105
106impl CodeToolDefinition {
107    pub fn schema() -> Value {
108        json!({
109            "name": "code",
110            "description": "Code semantics: parse, serialize, symbols, outline, definition, references, search_symbol, transform, summarize, metrics, exports, types, hierarchy, rename, grep_replace",
111            "inputSchema": {
112                "type": "object",
113                "properties": {
114                    "action": {
115                        "type": "string",
116                        "enum": ["parse", "serialize", "symbols", "outline", "definition", "references", "search_symbol", "transform", "summarize", "metrics", "exports", "types", "hierarchy", "rename", "grep_replace", "help"],
117                        "description": "Code action"
118                    },
119                    "uri": { "type": "string", "description": "File path" },
120                    "path": { "type": "string", "description": "File or directory path (alias for uri)" },
121                    "symbol": { "type": "string", "description": "Symbol name" },
122                    "language": { "type": "string", "description": "Programming language" },
123                    "query": { "type": "string", "description": "Search query or symbol name" },
124                    "spec": { "type": "string", "description": "Transform specification" },
125                    "text": { "type": "string", "description": "Raw text input" },
126                    "pattern": { "type": "string", "description": "File glob pattern" },
127                    "new_name": { "type": "string", "description": "New name for rename" },
128                    "replacement": { "type": "string", "description": "Replacement for grep_replace" },
129                    "max_results": { "type": "number", "default": 20 },
130                    "scope": { "type": "string", "description": "Search scope" },
131                    "ast": { "type": "object", "description": "Pre-parsed AST node to serialize back to text" }
132                },
133                "required": ["action"]
134            }
135        })
136    }
137}
138
139/// Code tool implementation
140pub struct CodeTool;
141
142impl CodeTool {
143    pub fn new() -> Self {
144        Self
145    }
146
147    fn resolve_uri<'a>(&self, args: &'a CodeToolArgs) -> Option<&'a str> {
148        args.uri.as_deref().or(args.path.as_deref())
149    }
150
151    fn is_code_file(path: &Path) -> bool {
152        matches!(
153            path.extension().and_then(|e| e.to_str()),
154            Some("rs" | "py" | "js" | "ts" | "tsx" | "jsx" | "go" | "java" | "c" | "cpp" | "h" | "hpp" | "rb" | "swift" | "kt" | "cs" | "lua" | "sh")
155        )
156    }
157
158    fn should_skip(entry: &std::fs::DirEntry) -> bool {
159        entry.file_name().to_str().map_or(false, |n| SKIP_DIRS.contains(&n))
160    }
161
162    fn walk_files(dir: &Path) -> Vec<std::path::PathBuf> {
163        let mut files = Vec::new();
164        fn walk(dir: &Path, files: &mut Vec<std::path::PathBuf>) {
165            if let Ok(entries) = std::fs::read_dir(dir) {
166                for entry in entries.flatten() {
167                    let path = entry.path();
168                    if path.is_dir() {
169                        if !CodeTool::should_skip(&entry) {
170                            walk(&path, files);
171                        }
172                    } else if CodeTool::is_code_file(&path) {
173                        files.push(path);
174                    }
175                }
176            }
177        }
178        walk(dir, &mut files);
179        files
180    }
181
182    fn detect_symbol(line: &str) -> Option<(&'static str, String)> {
183        let trimmed = line.trim();
184        let kind = if trimmed.starts_with("fn ") || trimmed.starts_with("pub fn ") || trimmed.starts_with("async fn ") || trimmed.starts_with("pub async fn ") {
185            Some("function")
186        } else if trimmed.starts_with("struct ") || trimmed.starts_with("pub struct ") {
187            Some("struct")
188        } else if trimmed.starts_with("enum ") || trimmed.starts_with("pub enum ") {
189            Some("enum")
190        } else if trimmed.starts_with("trait ") || trimmed.starts_with("pub trait ") {
191            Some("trait")
192        } else if trimmed.starts_with("class ") || trimmed.starts_with("export class ") {
193            Some("class")
194        } else if trimmed.starts_with("def ") || trimmed.starts_with("async def ") {
195            Some("function")
196        } else if trimmed.starts_with("function ") || trimmed.starts_with("export function ") || trimmed.starts_with("async function ") {
197            Some("function")
198        } else if trimmed.starts_with("interface ") || trimmed.starts_with("export interface ") {
199            Some("interface")
200        } else if trimmed.starts_with("type ") || trimmed.starts_with("export type ") {
201            Some("type")
202        } else {
203            None
204        };
205
206        kind.and_then(|k| {
207            let skip = ["fn", "pub", "async", "struct", "enum", "trait", "class", "def", "function", "export", "interface", "type"];
208            let name = trimmed.split_whitespace()
209                .find(|w| !skip.contains(w))
210                .unwrap_or("")
211                .trim_end_matches(|c: char| !c.is_alphanumeric() && c != '_')
212                .to_string();
213            if name.is_empty() { None } else { Some((k, name)) }
214        })
215    }
216
217    pub async fn execute(&self, args: CodeToolArgs) -> Result<Value> {
218        let action: CodeAction = args.action
219            .as_deref()
220            .unwrap_or("help")
221            .parse()?;
222
223        match action {
224            CodeAction::Parse => self.parse(&args).await,
225            CodeAction::Serialize => self.serialize(&args).await,
226            CodeAction::Symbols => self.symbols(&args).await,
227            CodeAction::Outline => self.outline(&args).await,
228            CodeAction::Definition => self.definition(&args).await,
229            CodeAction::References => self.references(&args).await,
230            CodeAction::SearchSymbol => self.search_symbol(&args).await,
231            CodeAction::Transform => self.transform(&args).await,
232            CodeAction::Summarize => self.summarize(&args).await,
233            CodeAction::Metrics => self.metrics(&args).await,
234            CodeAction::Exports => self.exports(&args).await,
235            CodeAction::Types => self.types(&args).await,
236            CodeAction::Hierarchy => self.hierarchy(&args).await,
237            CodeAction::Rename => self.rename(&args).await,
238            CodeAction::GrepReplace => self.grep_replace(&args).await,
239            CodeAction::Help => Ok(self.help()),
240        }
241    }
242
243    async fn parse(&self, args: &CodeToolArgs) -> Result<Value> {
244        let uri = self.resolve_uri(args).ok_or_else(|| anyhow!("uri required"))?;
245        let content = tokio::fs::read_to_string(uri).await?;
246        let lines = content.lines().count();
247        let lang = args.language.clone().unwrap_or_else(|| {
248            Path::new(uri).extension().and_then(|e| e.to_str()).unwrap_or("text").to_string()
249        });
250
251        Ok(json!({
252            "ok": true,
253            "data": { "uri": uri, "language": lang, "lines": lines },
254            "error": null,
255            "meta": { "tool": "code", "action": "parse" }
256        }))
257    }
258
259    /// Map a language name to its tree-sitter grammar.
260    fn ts_language(name: &str) -> Option<Language> {
261        Some(match name {
262            "rust" => tree_sitter_rust::language(),
263            "javascript" | "jsx" => tree_sitter_javascript::language(),
264            "typescript" => tree_sitter_typescript::language_typescript(),
265            "tsx" => tree_sitter_typescript::language_tsx(),
266            "python" => tree_sitter_python::language(),
267            "go" => tree_sitter_go::language(),
268            "java" => tree_sitter_java::language(),
269            "cpp" => tree_sitter_cpp::language(),
270            "c" => tree_sitter_c::language(),
271            _ => return None,
272        })
273    }
274
275    /// Resolve a tree-sitter grammar name from a file extension or explicit hint.
276    fn ts_lang_name(ext: &str) -> &'static str {
277        match ext {
278            "rs" => "rust",
279            "js" | "mjs" | "cjs" => "javascript",
280            "jsx" => "jsx",
281            "ts" => "typescript",
282            "tsx" => "tsx",
283            "py" => "python",
284            "go" => "go",
285            "java" => "java",
286            "cpp" | "cc" | "cxx" | "hpp" => "cpp",
287            "c" | "h" => "c",
288            _ => "unknown",
289        }
290    }
291
292    /// Reconstruct source by walking leaf nodes and refilling the inter-leaf
293    /// gaps (whitespace/comments-between) from the original bytes. Concatenating
294    /// leaf ranges plus gaps yields the exact input, so parse→serialize round-trips.
295    fn walk_leaves(node: Node, source: &str, out: &mut String, last_end: &mut usize) {
296        if node.child_count() == 0 {
297            let range = node.byte_range();
298            if range.start > *last_end {
299                out.push_str(&source[*last_end..range.start]);
300            }
301            out.push_str(&source[range.clone()]);
302            *last_end = range.end;
303            return;
304        }
305        let mut cursor = node.walk();
306        for child in node.children(&mut cursor) {
307            Self::walk_leaves(child, source, out, last_end);
308        }
309    }
310
311    /// Collapse a Python-shaped AST node (`{text}` leaf, else `{children}`) to
312    /// text, joining child fragments with a single space. Mirrors the SDK.
313    fn ast_node_text(node: &Value) -> String {
314        if let Some(text) = node.get("text").and_then(Value::as_str) {
315            return text.to_string();
316        }
317        node.get("children")
318            .and_then(Value::as_array)
319            .map(|children| children.iter().map(Self::ast_node_text).collect::<Vec<_>>().join(" "))
320            .unwrap_or_default()
321    }
322
323    async fn serialize(&self, args: &CodeToolArgs) -> Result<Value> {
324        // Path 1 — a pre-parsed AST node was supplied: collapse it to text
325        // exactly as the Python SDK does (leaf text joined by spaces).
326        if let Some(ast) = &args.ast {
327            let root = ast.get("root").unwrap_or(ast);
328            let text = Self::ast_node_text(root);
329            let lang = args.language.clone()
330                .or_else(|| ast.get("lang").and_then(Value::as_str).map(str::to_string))
331                .unwrap_or_else(|| "unknown".to_string());
332            return Ok(json!({
333                "ok": true,
334                "data": { "text": text, "lang": lang, "method": "ast", "supported": true },
335                "error": null,
336                "meta": { "tool": "code", "action": "serialize" }
337            }));
338        }
339
340        // Path 2 — round-trip: parse the source with tree-sitter, then
341        // reconstruct it from the parsed tree's leaves.
342        let source = if let Some(text) = &args.text {
343            text.clone()
344        } else if let Some(uri) = self.resolve_uri(args) {
345            tokio::fs::read_to_string(uri).await?
346        } else {
347            return Err(anyhow!("ast, text, or uri required"));
348        };
349
350        let lang_name = args.language.clone().unwrap_or_else(|| {
351            let ext = self.resolve_uri(args)
352                .and_then(|u| Path::new(u).extension().and_then(|e| e.to_str()))
353                .unwrap_or("");
354            Self::ts_lang_name(ext).to_string()
355        });
356
357        let language = Self::ts_language(&lang_name)
358            .ok_or_else(|| anyhow!("Unsupported language for serialize: {}", lang_name))?;
359        let mut parser = Parser::new();
360        parser.set_language(language).map_err(|e| anyhow!("set_language failed: {}", e))?;
361        let tree = parser.parse(source.as_bytes(), None)
362            .ok_or_else(|| anyhow!("Parse failed for language: {}", lang_name))?;
363
364        let mut text = String::with_capacity(source.len());
365        let mut last_end = 0usize;
366        Self::walk_leaves(tree.root_node(), &source, &mut text, &mut last_end);
367        if last_end < source.len() {
368            text.push_str(&source[last_end..]);
369        }
370
371        Ok(json!({
372            "ok": true,
373            "data": {
374                "text": text,
375                "lang": lang_name,
376                "method": "round_trip",
377                "supported": true,
378                "round_trip": text == source
379            },
380            "error": null,
381            "meta": { "tool": "code", "action": "serialize" }
382        }))
383    }
384
385    async fn symbols(&self, args: &CodeToolArgs) -> Result<Value> {
386        let uri = self.resolve_uri(args).ok_or_else(|| anyhow!("uri required"))?;
387        let content = args.text.clone().unwrap_or(tokio::fs::read_to_string(uri).await?);
388        let mut symbols = Vec::new();
389
390        for (i, line) in content.lines().enumerate() {
391            if let Some((kind, name)) = Self::detect_symbol(line) {
392                symbols.push(json!({ "name": name, "kind": kind, "line": i + 1 }));
393            }
394        }
395
396        Ok(json!({
397            "ok": true,
398            "data": { "uri": uri, "symbols": symbols, "count": symbols.len() },
399            "error": null,
400            "meta": { "tool": "code", "action": "symbols" }
401        }))
402    }
403
404    async fn outline(&self, args: &CodeToolArgs) -> Result<Value> {
405        let uri = self.resolve_uri(args).ok_or_else(|| anyhow!("uri required"))?;
406        let content = args.text.clone().unwrap_or(tokio::fs::read_to_string(uri).await?);
407        let mut symbols = Vec::new();
408        let mut imports = 0;
409
410        for (i, line) in content.lines().enumerate() {
411            let trimmed = line.trim();
412            if trimmed.starts_with("import ") || trimmed.starts_with("from ") || trimmed.starts_with("use ") || trimmed.starts_with("require") {
413                imports += 1;
414            }
415            if let Some((kind, name)) = Self::detect_symbol(line) {
416                let exported = trimmed.starts_with("export ") || trimmed.starts_with("pub ");
417                symbols.push(json!({ "name": name, "kind": kind, "line": i + 1, "exported": exported }));
418            }
419        }
420
421        Ok(json!({
422            "ok": true,
423            "data": { "uri": uri, "symbols": symbols, "imports": imports, "lines": content.lines().count() },
424            "error": null,
425            "meta": { "tool": "code", "action": "outline" }
426        }))
427    }
428
429    async fn definition(&self, args: &CodeToolArgs) -> Result<Value> {
430        let uri = self.resolve_uri(args).ok_or_else(|| anyhow!("uri required"))?;
431        let symbol = args.symbol.as_deref().or(args.query.as_deref()).ok_or_else(|| anyhow!("symbol or query required"))?;
432        let content = tokio::fs::read_to_string(uri).await?;
433
434        for (i, line) in content.lines().enumerate() {
435            if line.contains(symbol) {
436                if let Some(_) = Self::detect_symbol(line) {
437                    return Ok(json!({
438                        "ok": true,
439                        "data": { "uri": uri, "symbol": symbol, "line": i + 1, "text": line.trim() },
440                        "error": null,
441                        "meta": { "tool": "code", "action": "definition" }
442                    }));
443                }
444            }
445        }
446
447        Ok(json!({
448            "ok": false,
449            "data": null,
450            "error": { "code": "NOT_FOUND", "message": format!("Symbol '{}' not found", symbol) },
451            "meta": { "tool": "code", "action": "definition" }
452        }))
453    }
454
455    async fn references(&self, args: &CodeToolArgs) -> Result<Value> {
456        let symbol = args.symbol.as_deref().or(args.query.as_deref()).ok_or_else(|| anyhow!("symbol or query required"))?;
457        let uri = self.resolve_uri(args).ok_or_else(|| anyhow!("uri required"))?;
458        let content = tokio::fs::read_to_string(uri).await?;
459        let mut refs = Vec::new();
460
461        for (i, line) in content.lines().enumerate() {
462            if line.contains(symbol) {
463                refs.push(json!({ "line": i + 1, "text": line.trim() }));
464            }
465        }
466
467        Ok(json!({
468            "ok": true,
469            "data": { "uri": uri, "symbol": symbol, "references": refs, "count": refs.len() },
470            "error": null,
471            "meta": { "tool": "code", "action": "references" }
472        }))
473    }
474
475    async fn search_symbol(&self, args: &CodeToolArgs) -> Result<Value> {
476        let query = args.query.as_deref().ok_or_else(|| anyhow!("query required"))?;
477        let dir = self.resolve_uri(args).unwrap_or(".");
478        let max = args.max_results.unwrap_or(20);
479        let files = Self::walk_files(Path::new(dir));
480        let mut results = Vec::new();
481
482        for file in files {
483            if results.len() >= max { break; }
484            if let Ok(content) = std::fs::read_to_string(&file) {
485                for (i, line) in content.lines().enumerate() {
486                    if results.len() >= max { break; }
487                    if line.contains(query) {
488                        if Self::detect_symbol(line).is_some() || line.contains(query) {
489                            let is_def = Self::detect_symbol(line).is_some();
490                            results.push(json!({
491                                "uri": file.display().to_string(),
492                                "line": i + 1,
493                                "text": line.trim(),
494                                "type": if is_def { "definition" } else { "reference" }
495                            }));
496                        }
497                    }
498                }
499            }
500        }
501
502        Ok(json!({
503            "ok": true,
504            "data": { "query": query, "results": results, "count": results.len() },
505            "error": null,
506            "meta": { "tool": "code", "action": "search_symbol" }
507        }))
508    }
509
510    async fn transform(&self, _args: &CodeToolArgs) -> Result<Value> {
511        Ok(json!({
512            "ok": false,
513            "data": null,
514            "error": { "code": "NOT_IMPLEMENTED", "message": "Transform requires tree-sitter; use fs(action=apply_patch) for edits" },
515            "meta": { "tool": "code", "action": "transform" }
516        }))
517    }
518
519    async fn summarize(&self, args: &CodeToolArgs) -> Result<Value> {
520        let uri = self.resolve_uri(args);
521        let content = if let Some(text) = &args.text {
522            text.clone()
523        } else if let Some(uri) = uri {
524            tokio::fs::read_to_string(uri).await?
525        } else {
526            return Err(anyhow!("uri or text required"));
527        };
528
529        let lines = content.lines().count();
530        let chars = content.len();
531        let words = content.split_whitespace().count();
532
533        // Detect if it's a diff
534        let is_diff = content.contains("---") && content.contains("+++");
535        let summary = if is_diff {
536            let adds = content.lines().filter(|l| l.starts_with('+') && !l.starts_with("+++")).count();
537            let dels = content.lines().filter(|l| l.starts_with('-') && !l.starts_with("---")).count();
538            format!("Diff: {} lines, {} additions, {} deletions", lines, adds, dels)
539        } else {
540            format!("{} lines, {} words, {} bytes", lines, words, chars)
541        };
542
543        Ok(json!({
544            "ok": true,
545            "data": { "uri": uri, "lines": lines, "chars": chars, "words": words, "summary": summary },
546            "error": null,
547            "meta": { "tool": "code", "action": "summarize" }
548        }))
549    }
550
551    async fn metrics(&self, args: &CodeToolArgs) -> Result<Value> {
552        let dir = self.resolve_uri(args).unwrap_or(".");
553        let files = Self::walk_files(Path::new(dir));
554        let mut by_ext: HashMap<String, (usize, usize)> = HashMap::new(); // (files, lines)
555        let mut total_files = 0usize;
556        let mut total_lines = 0usize;
557
558        for file in files {
559            if let Ok(content) = std::fs::read_to_string(&file) {
560                let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("other");
561                let ext_key = format!(".{}", ext);
562                let lines = content.lines().count();
563                let entry = by_ext.entry(ext_key).or_insert((0, 0));
564                entry.0 += 1;
565                entry.1 += lines;
566                total_files += 1;
567                total_lines += lines;
568            }
569        }
570
571        let by_extension: HashMap<String, Value> = by_ext.into_iter()
572            .map(|(k, (f, l))| (k, json!({ "files": f, "lines": l })))
573            .collect();
574
575        Ok(json!({
576            "ok": true,
577            "data": { "total_files": total_files, "total_lines": total_lines, "by_extension": by_extension },
578            "error": null,
579            "meta": { "tool": "code", "action": "metrics" }
580        }))
581    }
582
583    async fn exports(&self, args: &CodeToolArgs) -> Result<Value> {
584        let uri = self.resolve_uri(args).ok_or_else(|| anyhow!("uri required"))?;
585        let content = tokio::fs::read_to_string(uri).await?;
586        let mut exports = Vec::new();
587
588        for line in content.lines() {
589            let trimmed = line.trim();
590            if trimmed.starts_with("export ") || trimmed.starts_with("pub ") || trimmed.starts_with("__all__") {
591                exports.push(trimmed.to_string());
592            }
593        }
594
595        Ok(json!({
596            "ok": true,
597            "data": { "uri": uri, "exports": exports, "count": exports.len() },
598            "error": null,
599            "meta": { "tool": "code", "action": "exports" }
600        }))
601    }
602
603    async fn types(&self, args: &CodeToolArgs) -> Result<Value> {
604        let uri = self.resolve_uri(args).ok_or_else(|| anyhow!("uri required"))?;
605        let content = tokio::fs::read_to_string(uri).await?;
606        let mut type_defs = Vec::new();
607
608        let re = Regex::new(r"(?:interface|type|enum|struct)\s+(\w+)").unwrap();
609        for (i, line) in content.lines().enumerate() {
610            if let Some(m) = re.captures(line) {
611                type_defs.push(json!({
612                    "name": m.get(1).map(|m| m.as_str()).unwrap_or(""),
613                    "line": i + 1,
614                    "text": line.trim()
615                }));
616            }
617        }
618
619        Ok(json!({
620            "ok": true,
621            "data": { "uri": uri, "types": type_defs, "count": type_defs.len() },
622            "error": null,
623            "meta": { "tool": "code", "action": "types" }
624        }))
625    }
626
627    async fn hierarchy(&self, args: &CodeToolArgs) -> Result<Value> {
628        let query = args.query.as_deref().ok_or_else(|| anyhow!("query (class name) required"))?;
629        let dir = self.resolve_uri(args).unwrap_or(".");
630        let files = Self::walk_files(Path::new(dir));
631        let mut classes: HashMap<String, Vec<String>> = HashMap::new();
632
633        let re = Regex::new(r"class\s+(\w+)(?:\s+extends\s+(\w+)|\s*\((\w+)\))?").unwrap();
634        for file in files {
635            if let Ok(content) = std::fs::read_to_string(&file) {
636                for cap in re.captures_iter(&content) {
637                    let name = cap.get(1).map(|m| m.as_str().to_string()).unwrap_or_default();
638                    let parent = cap.get(2).or(cap.get(3)).map(|m| m.as_str().to_string());
639                    classes.entry(name.clone()).or_default();
640                    if let Some(p) = parent {
641                        classes.entry(p.clone()).or_default().push(name);
642                    }
643                }
644            }
645        }
646
647        fn build_tree(name: &str, classes: &HashMap<String, Vec<String>>, depth: usize) -> String {
648            let mut out = "  ".repeat(depth) + name + "\n";
649            if let Some(children) = classes.get(name) {
650                for child in children {
651                    out += &build_tree(child, classes, depth + 1);
652                }
653            }
654            out
655        }
656
657        let tree = build_tree(query, &classes, 0);
658        let children = classes.get(query).cloned().unwrap_or_default();
659
660        Ok(json!({
661            "ok": true,
662            "data": { "root": query, "tree": tree, "children": children },
663            "error": null,
664            "meta": { "tool": "code", "action": "hierarchy" }
665        }))
666    }
667
668    async fn rename(&self, args: &CodeToolArgs) -> Result<Value> {
669        let query = args.query.as_deref().ok_or_else(|| anyhow!("query (old name) required"))?;
670        let new_name = args.new_name.as_deref().ok_or_else(|| anyhow!("new_name required"))?;
671        let dir = self.resolve_uri(args).unwrap_or(".");
672        let files = Self::walk_files(Path::new(dir));
673        let re = Regex::new(&format!(r"\b{}\b", regex::escape(query)))?;
674        let mut total_changes = 0usize;
675        let mut changed = Vec::new();
676
677        for file in files {
678            if let Ok(content) = std::fs::read_to_string(&file) {
679                if re.is_match(&content) {
680                    let count = re.find_iter(&content).count();
681                    let updated = re.replace_all(&content, new_name).to_string();
682                    std::fs::write(&file, &updated)?;
683                    total_changes += count;
684                    changed.push(format!("{}: {} replacements", file.display(), count));
685                }
686            }
687        }
688
689        Ok(json!({
690            "ok": true,
691            "data": { "old_name": query, "new_name": new_name, "files_changed": changed.len(), "total_replacements": total_changes, "changed": changed },
692            "error": null,
693            "meta": { "tool": "code", "action": "rename" }
694        }))
695    }
696
697    async fn grep_replace(&self, args: &CodeToolArgs) -> Result<Value> {
698        let pattern = args.query.as_deref().ok_or_else(|| anyhow!("query (pattern) required"))?;
699        let replacement = args.replacement.as_deref().ok_or_else(|| anyhow!("replacement required"))?;
700        let dir = self.resolve_uri(args).unwrap_or(".");
701        let files = Self::walk_files(Path::new(dir));
702        let re = Regex::new(pattern)?;
703        let mut total_changes = 0usize;
704        let mut changed = Vec::new();
705
706        for file in files {
707            if let Ok(content) = std::fs::read_to_string(&file) {
708                if re.is_match(&content) {
709                    let count = re.find_iter(&content).count();
710                    let updated = re.replace_all(&content, replacement).to_string();
711                    std::fs::write(&file, &updated)?;
712                    total_changes += count;
713                    changed.push(format!("{}: {}", file.display(), count));
714                }
715            }
716        }
717
718        Ok(json!({
719            "ok": true,
720            "data": { "pattern": pattern, "replacement": replacement, "files_changed": changed.len(), "total_replacements": total_changes, "changed": changed },
721            "error": null,
722            "meta": { "tool": "code", "action": "grep_replace" }
723        }))
724    }
725
726    fn help(&self) -> Value {
727        json!({
728            "ok": true,
729            "data": {
730                "tool": "code",
731                "actions": {
732                    "parse": "Parse source to AST (requires uri)",
733                    "serialize": "Convert AST to text (round-trip; requires ast, or text/uri)",
734                    "symbols": "List symbols in file (requires uri)",
735                    "outline": "Symbols with imports/exports (requires uri)",
736                    "definition": "Go to symbol definition (requires uri, symbol/query)",
737                    "references": "Find all references (requires uri, symbol/query)",
738                    "search_symbol": "Find symbols across project (requires query)",
739                    "transform": "Codemod → Patch (requires uri, spec)",
740                    "summarize": "Compress to summary (requires uri or text)",
741                    "metrics": "Count files/lines by extension (optional uri for dir)",
742                    "exports": "Extract public exports (requires uri)",
743                    "types": "Find type definitions (requires uri)",
744                    "hierarchy": "Build class inheritance tree (requires query)",
745                    "rename": "Rename symbols across files (requires query, new_name)",
746                    "grep_replace": "Pattern replacement across files (requires query, replacement)"
747                }
748            },
749            "error": null,
750            "meta": { "tool": "code", "action": "help" }
751        })
752    }
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758
759    #[test]
760    fn test_code_action_parse() {
761        let action: CodeAction = "parse".parse().unwrap();
762        assert_eq!(action, CodeAction::Parse);
763    }
764
765    #[test]
766    fn test_code_action_aliases() {
767        let action: CodeAction = "outline".parse().unwrap();
768        assert_eq!(action, CodeAction::Outline);
769        let action: CodeAction = "search".parse().unwrap();
770        assert_eq!(action, CodeAction::SearchSymbol);
771    }
772
773    #[test]
774    fn test_code_action_new_variants() {
775        assert_eq!("metrics".parse::<CodeAction>().unwrap(), CodeAction::Metrics);
776        assert_eq!("exports".parse::<CodeAction>().unwrap(), CodeAction::Exports);
777        assert_eq!("types".parse::<CodeAction>().unwrap(), CodeAction::Types);
778        assert_eq!("hierarchy".parse::<CodeAction>().unwrap(), CodeAction::Hierarchy);
779        assert_eq!("rename".parse::<CodeAction>().unwrap(), CodeAction::Rename);
780        assert_eq!("grep_replace".parse::<CodeAction>().unwrap(), CodeAction::GrepReplace);
781        assert_eq!("serialize".parse::<CodeAction>().unwrap(), CodeAction::Serialize);
782    }
783
784    #[tokio::test]
785    async fn test_serialize_round_trips_source() {
786        let src = "fn main() {\n    let x = 1;\n    println!(\"{}\", x);\n}\n";
787        let tool = CodeTool::new();
788        let args = CodeToolArgs {
789            action: Some("serialize".into()),
790            text: Some(src.to_string()),
791            language: Some("rust".into()),
792            ..Default::default()
793        };
794        let out = tool.serialize(&args).await.unwrap();
795        assert_eq!(out["data"]["text"].as_str().unwrap(), src);
796        assert_eq!(out["data"]["round_trip"], json!(true));
797        assert_eq!(out["data"]["method"], json!("round_trip"));
798    }
799
800    #[tokio::test]
801    async fn test_serialize_from_ast_node() {
802        let ast = json!({
803            "lang": "python",
804            "root": { "children": [ { "text": "x" }, { "text": "=" }, { "text": "1" } ] }
805        });
806        let tool = CodeTool::new();
807        let args = CodeToolArgs {
808            action: Some("serialize".into()),
809            ast: Some(ast),
810            ..Default::default()
811        };
812        let out = tool.serialize(&args).await.unwrap();
813        assert_eq!(out["data"]["text"].as_str().unwrap(), "x = 1");
814        assert_eq!(out["data"]["lang"], json!("python"));
815        assert_eq!(out["data"]["method"], json!("ast"));
816    }
817}