Skip to main content

sparrow/tools/
code_nav.rs

1//! Code navigation tools that bring Sparrow's self-coding loop closer to a
2//! frontier agent's: `glob` (find files by name pattern) and `symbols`
3//! (find where a symbol is defined / who calls it). The `symbols` tool is a
4//! regex-based index today; a tree-sitter AST upgrade keeps the same name/schema.
5
6use async_trait::async_trait;
7use serde_json::json;
8use std::path::{Path, PathBuf};
9
10use super::{Tool, ToolCtx, ToolResult};
11use crate::event::{Block, RiskLevel};
12
13const SKIP_DIRS: &[&str] = &[".git", "target", "node_modules", "dist", "build", ".venv"];
14const MAX_RESULTS: usize = 200;
15
16fn walk(root: &Path, out: &mut Vec<PathBuf>) {
17    if out.len() >= 5000 {
18        return;
19    }
20    let Ok(entries) = std::fs::read_dir(root) else {
21        return;
22    };
23    for entry in entries.flatten() {
24        let path = entry.path();
25        if path.is_dir() {
26            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
27            if SKIP_DIRS.contains(&name) {
28                continue;
29            }
30            walk(&path, out);
31        } else {
32            out.push(path);
33        }
34    }
35}
36
37/// Translate a glob (`*`, `**`, `?`) into a regex anchored on the full rel path.
38fn glob_to_regex(pattern: &str) -> String {
39    let mut re = String::from("(?i)^");
40    let mut chars = pattern.chars().peekable();
41    while let Some(c) = chars.next() {
42        match c {
43            '*' => {
44                if chars.peek() == Some(&'*') {
45                    chars.next();
46                    if chars.peek() == Some(&'/') {
47                        chars.next();
48                    }
49                    re.push_str(".*");
50                } else {
51                    re.push_str("[^/\\\\]*");
52                }
53            }
54            '?' => re.push('.'),
55            '.' | '+' | '(' | ')' | '|' | '[' | ']' | '{' | '}' | '^' | '$' | '\\' => {
56                re.push('\\');
57                re.push(c);
58            }
59            '/' => re.push_str("[/\\\\]"),
60            _ => re.push(c),
61        }
62    }
63    re.push('$');
64    re
65}
66
67// ─── Glob tool ─────────────────────────────────────────────────────────────
68
69pub struct Glob;
70
71#[async_trait]
72impl Tool for Glob {
73    fn name(&self) -> &str {
74        "glob"
75    }
76    fn description(&self) -> &str {
77        "Find files by name pattern (e.g. '**/*.rs', 'src/**/mod.rs'). Returns matching paths."
78    }
79    fn schema(&self) -> serde_json::Value {
80        json!({
81            "type": "object",
82            "properties": {
83                "pattern": { "type": "string", "description": "Glob pattern, e.g. **/*.rs" }
84            },
85            "required": ["pattern"]
86        })
87    }
88    fn risk(&self) -> RiskLevel {
89        RiskLevel::ReadOnly
90    }
91    async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
92        let pattern = args["pattern"].as_str().unwrap_or("");
93        if pattern.is_empty() {
94            return Ok(ToolResult::error("glob: 'pattern' is required"));
95        }
96        let re = regex::Regex::new(&glob_to_regex(pattern))
97            .map_err(|e| anyhow::anyhow!("bad glob pattern: {}", e))?;
98        let root = ctx.workspace_root.clone();
99        let mut files = Vec::new();
100        walk(&root, &mut files);
101        let mut matches: Vec<String> = files
102            .iter()
103            .filter_map(|p| {
104                let rel = p.strip_prefix(&root).unwrap_or(p);
105                let rel_str = rel.to_string_lossy().replace('\\', "/");
106                if re.is_match(&rel_str) {
107                    Some(rel_str)
108                } else {
109                    None
110                }
111            })
112            .collect();
113        matches.sort();
114        matches.truncate(MAX_RESULTS);
115        if matches.is_empty() {
116            Ok(ToolResult::text(format!("no files match '{}'", pattern)))
117        } else {
118            Ok(ToolResult::ok(vec![Block::Text(matches.join("\n"))]))
119        }
120    }
121}
122
123// ─── Symbols tool (regex index) ────────────────────────────────────────────
124
125pub struct Symbols;
126
127#[async_trait]
128impl Tool for Symbols {
129    fn name(&self) -> &str {
130        "symbols"
131    }
132    fn description(&self) -> &str {
133        "Find where a symbol is defined (mode=definition), list a file's symbols (mode=outline), or find callers (mode=callers). Languages: rust/py/js/ts."
134    }
135    fn schema(&self) -> serde_json::Value {
136        json!({
137            "type": "object",
138            "properties": {
139                "name": { "type": "string", "description": "Symbol name (or file path for outline)" },
140                "mode": { "type": "string", "description": "definition | outline | callers (default: definition)" }
141            },
142            "required": ["name"]
143        })
144    }
145    fn risk(&self) -> RiskLevel {
146        RiskLevel::ReadOnly
147    }
148    async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
149        let name = args["name"].as_str().unwrap_or("").trim();
150        let mode = args["mode"].as_str().unwrap_or("definition");
151        if name.is_empty() {
152            return Ok(ToolResult::error("symbols: 'name' is required"));
153        }
154        let root = ctx.workspace_root.clone();
155
156        #[cfg(feature = "treesitter")]
157        {
158            if mode == "definition" {
159                let index = crate::memory::symbol_index::SymbolIndex::build(&root);
160                let hits: Vec<String> = index
161                    .find_definition(name)
162                    .into_iter()
163                    .take(MAX_RESULTS)
164                    .map(format_symbol_def)
165                    .collect();
166                return if hits.is_empty() {
167                    Ok(ToolResult::text(format!(
168                        "no definition found for '{}'",
169                        name
170                    )))
171                } else {
172                    Ok(ToolResult::ok(vec![Block::Text(hits.join("\n"))]))
173                };
174            }
175            if mode == "outline" {
176                let index = crate::memory::symbol_index::SymbolIndex::build(&root);
177                let requested = Path::new(name);
178                let path = if requested.is_absolute() {
179                    requested
180                        .strip_prefix(&root)
181                        .map(PathBuf::from)
182                        .unwrap_or_else(|_| requested.to_path_buf())
183                } else {
184                    requested.to_path_buf()
185                };
186                let hits: Vec<String> = index
187                    .outline(&path)
188                    .into_iter()
189                    .take(MAX_RESULTS)
190                    .map(|def| {
191                        format!(
192                            "{}:{}: {} {}",
193                            def.file.to_string_lossy().replace('\\', "/"),
194                            def.line,
195                            def.kind.as_str(),
196                            def.signature
197                        )
198                    })
199                    .collect();
200                return if hits.is_empty() {
201                    Ok(ToolResult::text(format!("no symbols in {}", name)))
202                } else {
203                    Ok(ToolResult::ok(vec![Block::Text(hits.join("\n"))]))
204                };
205            }
206        }
207
208        // Build the regex for the requested mode.
209        let esc = regex::escape(name);
210        let pattern = match mode {
211            "callers" => format!(r"\b{}\s*\(", esc),
212            "outline" => {
213                // List definitions in a single file (name = path).
214                return outline(&root, name);
215            }
216            _ => format!(
217                r"\b(fn|struct|enum|trait|type|const|static|impl|class|def|function)\b[^\n]*\b{}\b",
218                esc
219            ),
220        };
221        let re = regex::Regex::new(&pattern).map_err(|e| anyhow::anyhow!("regex error: {}", e))?;
222
223        let mut files = Vec::new();
224        walk(&root, &mut files);
225        let mut hits: Vec<String> = Vec::new();
226        for path in &files {
227            if !is_code_file(path) {
228                continue;
229            }
230            let Ok(content) = std::fs::read_to_string(path) else {
231                continue;
232            };
233            for (i, line) in content.lines().enumerate() {
234                if re.is_match(line) {
235                    let rel = path.strip_prefix(&root).unwrap_or(path);
236                    hits.push(format!(
237                        "{}:{}: {}",
238                        rel.to_string_lossy().replace('\\', "/"),
239                        i + 1,
240                        line.trim()
241                    ));
242                    if hits.len() >= MAX_RESULTS {
243                        break;
244                    }
245                }
246            }
247            if hits.len() >= MAX_RESULTS {
248                break;
249            }
250        }
251        if hits.is_empty() {
252            Ok(ToolResult::text(format!(
253                "no {} found for '{}'",
254                mode, name
255            )))
256        } else {
257            Ok(ToolResult::ok(vec![Block::Text(hits.join("\n"))]))
258        }
259    }
260}
261
262#[cfg(feature = "treesitter")]
263fn format_symbol_def(def: &crate::memory::symbol_index::SymbolDef) -> String {
264    format!(
265        "{}:{}: {} {}",
266        def.file.to_string_lossy().replace('\\', "/"),
267        def.line,
268        def.kind.as_str(),
269        def.signature
270    )
271}
272
273fn is_code_file(path: &Path) -> bool {
274    matches!(
275        path.extension().and_then(|e| e.to_str()),
276        Some("rs" | "py" | "js" | "ts" | "tsx" | "jsx" | "go" | "java" | "c" | "cpp" | "h")
277    )
278}
279
280fn outline(root: &Path, file_arg: &str) -> anyhow::Result<ToolResult> {
281    let path = if Path::new(file_arg).is_absolute() {
282        PathBuf::from(file_arg)
283    } else {
284        root.join(file_arg)
285    };
286    let Ok(content) = std::fs::read_to_string(&path) else {
287        return Ok(ToolResult::error(format!("cannot read {}", file_arg)));
288    };
289    let re = regex::Regex::new(
290        r"^\s*(pub\s+)?(fn|struct|enum|trait|impl|type|const|static|class|def|function)\b.*",
291    )
292    .unwrap();
293    let mut out = Vec::new();
294    for (i, line) in content.lines().enumerate() {
295        if re.is_match(line) {
296            out.push(format!("{}: {}", i + 1, line.trim()));
297            if out.len() >= MAX_RESULTS {
298                break;
299            }
300        }
301    }
302    if out.is_empty() {
303        Ok(ToolResult::text(format!("no symbols in {}", file_arg)))
304    } else {
305        Ok(ToolResult::ok(vec![Block::Text(out.join("\n"))]))
306    }
307}