1use super::Tool;
4use super::native_search::{ensure_binary, run_cmd};
5use async_trait::async_trait;
6use serde_json::{json, Value};
7use std::path::PathBuf;
8use std::process::Stdio;
9
10pub struct AstGrepTool {
13 workspace_root: PathBuf,
14}
15
16impl AstGrepTool {
17 pub fn new(workspace_root: PathBuf) -> Self {
18 Self { workspace_root }
19 }
20}
21
22#[async_trait]
23impl Tool for AstGrepTool {
24 fn name(&self) -> &str { "ast_grep" }
25
26 fn description(&self) -> &str {
27 "ast-grep — structural code search and rewrite using AST patterns. \
28 Unlike regex, this matches code by syntax tree structure. Use $NAME for \
29 single-node wildcards, $$$ARGS for variadic (multiple nodes). \
30 Actions: 'search' finds matches, 'rewrite' transforms them in-place. \
31 Examples: pattern='fn $NAME($$$ARGS)' finds all functions. \
32 pattern='$EXPR.unwrap()' rewrite='$EXPR?' replaces unwrap with ?. \
33 Supports: rust, python, javascript, typescript, go, c, cpp, java."
34 }
35
36 fn parameters_schema(&self) -> Value {
37 json!({
38 "type": "object",
39 "properties": {
40 "action": {
41 "type": "string",
42 "enum": ["search", "rewrite"],
43 "description": "search: find matching code. rewrite: transform matching code in-place."
44 },
45 "pattern": {
46 "type": "string",
47 "description": "AST pattern to match. Use $VAR for wildcards, $$$VAR for variadic. e.g. 'fn $NAME($$$ARGS) -> $RET { $$$ }'"
48 },
49 "rewrite": {
50 "type": "string",
51 "description": "Replacement pattern (only for action=rewrite). Use captured $VARs. e.g. '$EXPR?'"
52 },
53 "path": {
54 "type": "string",
55 "description": "File or directory to search/rewrite"
56 },
57 "lang": {
58 "type": "string",
59 "description": "Language: rust, python, javascript, typescript, go, c, cpp, java (default: auto-detect)"
60 }
61 },
62 "required": ["action", "pattern", "path"]
63 })
64 }
65
66 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
67 use thulp_core::{Parameter, ParameterType};
68 thulp_core::ToolDefinition::builder(self.name())
69 .description(self.description())
70 .parameter(
71 Parameter::builder("action")
72 .param_type(ParameterType::String)
73 .required(true)
74 .description("search: find matching code. rewrite: transform matching code in-place.")
75 .build(),
76 )
77 .parameter(
78 Parameter::builder("pattern")
79 .param_type(ParameterType::String)
80 .required(true)
81 .description("AST pattern to match. Use $VAR for wildcards, $$$VAR for variadic. e.g. 'fn $NAME($$$ARGS) -> $RET { $$$ }'")
82 .build(),
83 )
84 .parameter(
85 Parameter::builder("rewrite")
86 .param_type(ParameterType::String)
87 .required(false)
88 .description("Replacement pattern (only for action=rewrite). Use captured $VARs. e.g. '$EXPR?'")
89 .build(),
90 )
91 .parameter(
92 Parameter::builder("path")
93 .param_type(ParameterType::String)
94 .required(true)
95 .description("File or directory to search/rewrite")
96 .build(),
97 )
98 .parameter(
99 Parameter::builder("lang")
100 .param_type(ParameterType::String)
101 .required(false)
102 .description("Language: rust, python, javascript, typescript, go, c, cpp, java (default: auto-detect)")
103 .build(),
104 )
105 .build()
106 }
107
108 async fn execute(&self, args: Value) -> crate::Result<Value> {
109 ensure_binary("ast-grep", &self.workspace_root).await?;
110
111 let action = args["action"].as_str()
112 .ok_or_else(|| crate::PawanError::Tool("action required (search or rewrite)".into()))?;
113 let pattern = args["pattern"].as_str()
114 .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
115 let path = args["path"].as_str()
116 .ok_or_else(|| crate::PawanError::Tool("path required".into()))?;
117
118 let mut cmd_args: Vec<String> = vec!["run".into()];
119
120 if let Some(lang) = args["lang"].as_str() {
121 cmd_args.push("--lang".into());
122 cmd_args.push(lang.into());
123 }
124
125 cmd_args.push("--pattern".into());
126 cmd_args.push(pattern.into());
127
128 match action {
129 "search" => {
130 cmd_args.push(path.into());
131 }
132 "rewrite" => {
133 let rewrite = args["rewrite"].as_str()
134 .ok_or_else(|| crate::PawanError::Tool("rewrite pattern required for action=rewrite".into()))?;
135 cmd_args.push("--rewrite".into());
136 cmd_args.push(rewrite.into());
137 cmd_args.push("--update-all".into());
138 cmd_args.push(path.into());
139 }
140 _ => {
141 return Err(crate::PawanError::Tool(
142 format!("Unknown action: {}. Use 'search' or 'rewrite'", action),
143 ));
144 }
145 }
146
147 let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
148 let (stdout, stderr, success) = run_cmd("ast-grep", &cmd_refs, &self.workspace_root).await
149 .map_err(crate::PawanError::Tool)?;
150
151 let match_count = stdout.lines()
152 .filter(|l| l.starts_with('/') || l.contains("│"))
153 .count();
154
155 Ok(json!({
156 "success": success,
157 "action": action,
158 "matches": match_count,
159 "output": stdout,
160 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
161 }))
162 }
163}
164
165pub struct LspTool {
168 workspace_root: PathBuf,
169}
170
171impl LspTool {
172 pub fn new(workspace_root: PathBuf) -> Self {
173 Self { workspace_root }
174 }
175}
176
177#[async_trait]
178impl Tool for LspTool {
179 fn name(&self) -> &str { "lsp" }
180
181 fn description(&self) -> &str {
182 "LSP code intelligence via rust-analyzer. Provides type-aware code understanding \
183 that grep/ast-grep can't: diagnostics without cargo check, structural search with \
184 type info, symbol extraction, and analysis stats. Actions: diagnostics (find errors), \
185 search (structural pattern search), ssr (structural search+replace with types), \
186 symbols (parse file symbols), analyze (project-wide type stats)."
187 }
188
189 fn parameters_schema(&self) -> Value {
190 json!({
191 "type": "object",
192 "properties": {
193 "action": {
194 "type": "string",
195 "enum": ["diagnostics", "search", "ssr", "symbols", "analyze"],
196 "description": "diagnostics: find errors/warnings in project. \
197 search: structural pattern search (e.g. '$a.foo($b)'). \
198 ssr: structural search+replace (e.g. '$a.unwrap() ==>> $a?'). \
199 symbols: parse file and list symbols. \
200 analyze: project-wide type analysis stats."
201 },
202 "pattern": {
203 "type": "string",
204 "description": "For search: pattern like '$a.foo($b)'. For ssr: rule like '$a.unwrap() ==>> $a?'"
205 },
206 "path": {
207 "type": "string",
208 "description": "Project path (directory with Cargo.toml) for diagnostics/analyze. File path for symbols."
209 },
210 "severity": {
211 "type": "string",
212 "enum": ["error", "warning", "info", "hint"],
213 "description": "Minimum severity for diagnostics (default: warning)"
214 }
215 },
216 "required": ["action"]
217 })
218 }
219
220 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
221 use thulp_core::{Parameter, ParameterType};
222 thulp_core::ToolDefinition::builder(self.name())
223 .description(self.description())
224 .parameter(
225 Parameter::builder("action")
226 .param_type(ParameterType::String)
227 .required(true)
228 .description("diagnostics: find errors/warnings in project. \
229 search: structural pattern search (e.g. '$a.foo($b)'). \
230 ssr: structural search+replace (e.g. '$a.unwrap() ==>> $a?'). \
231 symbols: parse file and list symbols. \
232 analyze: project-wide type analysis stats.")
233 .build(),
234 )
235 .parameter(
236 Parameter::builder("pattern")
237 .param_type(ParameterType::String)
238 .required(false)
239 .description("For search: pattern like '$a.foo($b)'. For ssr: rule like '$a.unwrap() ==>> $a?'")
240 .build(),
241 )
242 .parameter(
243 Parameter::builder("path")
244 .param_type(ParameterType::String)
245 .required(false)
246 .description("Project path (directory with Cargo.toml) for diagnostics/analyze. File path for symbols.")
247 .build(),
248 )
249 .parameter(
250 Parameter::builder("severity")
251 .param_type(ParameterType::String)
252 .required(false)
253 .description("Minimum severity for diagnostics (default: warning)")
254 .build(),
255 )
256 .build()
257 }
258
259 async fn execute(&self, args: Value) -> crate::Result<Value> {
260 ensure_binary("rust-analyzer", &self.workspace_root).await?;
261
262 let action = args["action"].as_str()
263 .ok_or_else(|| crate::PawanError::Tool("action required".into()))?;
264
265 let timeout_dur = std::time::Duration::from_secs(60);
266
267 match action {
268 "diagnostics" => {
269 let path = args["path"].as_str()
270 .unwrap_or(self.workspace_root.to_str().unwrap_or("."));
271 let mut cmd_args = vec!["diagnostics", path];
272 if let Some(sev) = args["severity"].as_str() {
273 cmd_args.extend(["--severity", sev]);
274 }
275 let result = tokio::time::timeout(timeout_dur,
276 run_cmd("rust-analyzer", &cmd_args, &self.workspace_root)
277 ).await;
278 match result {
279 Ok(Ok((stdout, stderr, success))) => Ok(json!({
280 "success": success,
281 "diagnostics": stdout,
282 "count": stdout.lines().filter(|l| !l.is_empty()).count(),
283 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
284 })),
285 Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
286 Err(_) => Err(crate::PawanError::Tool("rust-analyzer diagnostics timed out (60s)".into())),
287 }
288 }
289 "search" => {
290 let pattern = args["pattern"].as_str()
291 .ok_or_else(|| crate::PawanError::Tool("pattern required for search".into()))?;
292 let result = tokio::time::timeout(timeout_dur,
293 run_cmd("rust-analyzer", &["search", pattern], &self.workspace_root)
294 ).await;
295 match result {
296 Ok(Ok((stdout, stderr, success))) => Ok(json!({
297 "success": success,
298 "matches": stdout,
299 "count": stdout.lines().filter(|l| !l.is_empty()).count(),
300 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
301 })),
302 Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
303 Err(_) => Err(crate::PawanError::Tool("rust-analyzer search timed out (60s)".into())),
304 }
305 }
306 "ssr" => {
307 let pattern = args["pattern"].as_str()
308 .ok_or_else(|| crate::PawanError::Tool(
309 "pattern required for ssr (format: '$a.unwrap() ==>> $a?')".into()
310 ))?;
311 let result = tokio::time::timeout(timeout_dur,
312 run_cmd("rust-analyzer", &["ssr", pattern], &self.workspace_root)
313 ).await;
314 match result {
315 Ok(Ok((stdout, stderr, success))) => Ok(json!({
316 "success": success,
317 "output": stdout,
318 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
319 })),
320 Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
321 Err(_) => Err(crate::PawanError::Tool("rust-analyzer ssr timed out (60s)".into())),
322 }
323 }
324 "symbols" => {
325 let path = args["path"].as_str()
326 .ok_or_else(|| crate::PawanError::Tool("path required for symbols".into()))?;
327 let full_path = if std::path::Path::new(path).is_absolute() {
328 PathBuf::from(path)
329 } else {
330 self.workspace_root.join(path)
331 };
332 let content = tokio::fs::read_to_string(&full_path).await
333 .map_err(|e| crate::PawanError::Tool(format!("Failed to read {}: {}", path, e)))?;
334
335 let mut child = tokio::process::Command::new("rust-analyzer")
336 .arg("symbols")
337 .stdin(Stdio::piped())
338 .stdout(Stdio::piped())
339 .stderr(Stdio::piped())
340 .spawn()
341 .map_err(|e| crate::PawanError::Tool(format!("Failed to spawn rust-analyzer: {}", e)))?;
342
343 if let Some(mut stdin) = child.stdin.take() {
344 use tokio::io::AsyncWriteExt;
345 let _ = stdin.write_all(content.as_bytes()).await;
346 drop(stdin);
347 }
348
349 let output = child.wait_with_output().await
350 .map_err(|e| crate::PawanError::Tool(format!("rust-analyzer symbols failed: {}", e)))?;
351
352 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
353 Ok(json!({
354 "success": output.status.success(),
355 "symbols": stdout,
356 "count": stdout.lines().filter(|l| !l.is_empty()).count()
357 }))
358 }
359 "analyze" => {
360 let path = args["path"].as_str()
361 .unwrap_or(self.workspace_root.to_str().unwrap_or("."));
362 let result = tokio::time::timeout(timeout_dur,
363 run_cmd("rust-analyzer", &["analysis-stats", "--skip-inference", path], &self.workspace_root)
364 ).await;
365 match result {
366 Ok(Ok((stdout, stderr, success))) => Ok(json!({
367 "success": success,
368 "stats": stdout,
369 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
370 })),
371 Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
372 Err(_) => Err(crate::PawanError::Tool("rust-analyzer analysis-stats timed out (60s)".into())),
373 }
374 }
375 _ => Err(crate::PawanError::Tool(
376 format!("Unknown action: {action}. Use diagnostics/search/ssr/symbols/analyze")
377 )),
378 }
379 }
380}
381
382#[cfg(test)]
385mod tests {
386 use super::*;
387 use tempfile::TempDir;
388
389 #[tokio::test]
390 async fn test_ast_grep_tool_schema() {
391 let tmp = TempDir::new().unwrap();
392 let tool = AstGrepTool::new(tmp.path().to_path_buf());
393 assert_eq!(tool.name(), "ast_grep");
394 let schema = tool.parameters_schema();
395 assert!(schema["properties"]["action"].is_object());
396 assert!(schema["properties"]["pattern"].is_object());
397 }
398
399 #[tokio::test]
400 async fn test_lsp_tool_schema() {
401 let tmp = TempDir::new().unwrap();
402 let tool = LspTool::new(tmp.path().to_path_buf());
403 assert_eq!(tool.name(), "lsp");
404 let schema = tool.parameters_schema();
405 assert!(schema["properties"]["action"].is_object());
406 }
407}