sparrow/tools/
code_nav.rs1use 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
37fn 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
67pub 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
123pub 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 let esc = regex::escape(name);
210 let pattern = match mode {
211 "callers" => format!(r"\b{}\s*\(", esc),
212 "outline" => {
213 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}