1use super::native_search::{ensure_binary, run_cmd};
4use super::Tool;
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 {
25 "ast_grep"
26 }
27
28 fn description(&self) -> &str {
29 "ast-grep — structural code search and rewrite using AST patterns. \
30 Unlike regex, this matches code by syntax tree structure. Use $NAME for \
31 single-node wildcards, $$$ARGS for variadic (multiple nodes). \
32 Actions: 'search' finds matches, 'rewrite' transforms them in-place. \
33 Examples: pattern='fn $NAME($$$ARGS)' finds all functions. \
34 pattern='$EXPR.unwrap()' rewrite='$EXPR?' replaces unwrap with ?. \
35 Supports: rust, python, javascript, typescript, go, c, cpp, java."
36 }
37
38 fn parameters_schema(&self) -> Value {
39 json!({
40 "type": "object",
41 "properties": {
42 "action": {
43 "type": "string",
44 "enum": ["search", "rewrite"],
45 "description": "search: find matching code. rewrite: transform matching code in-place."
46 },
47 "pattern": {
48 "type": "string",
49 "description": "AST pattern to match. Use $VAR for wildcards, $$$VAR for variadic. e.g. 'fn $NAME($$$ARGS) -> $RET { $$$ }'"
50 },
51 "rewrite": {
52 "type": "string",
53 "description": "Replacement pattern (only for action=rewrite). Use captured $VARs. e.g. '$EXPR?'"
54 },
55 "path": {
56 "type": "string",
57 "description": "File or directory to search/rewrite"
58 },
59 "lang": {
60 "type": "string",
61 "description": "Language: rust, python, javascript, typescript, go, c, cpp, java (default: auto-detect)"
62 }
63 },
64 "required": ["action", "pattern", "path"]
65 })
66 }
67
68 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
69 use thulp_core::{Parameter, ParameterType};
70 thulp_core::ToolDefinition::builder(self.name())
71 .description(self.description())
72 .parameter(
73 Parameter::builder("action")
74 .param_type(ParameterType::String)
75 .required(true)
76 .description("search: find matching code. rewrite: transform matching code in-place.")
77 .build(),
78 )
79 .parameter(
80 Parameter::builder("pattern")
81 .param_type(ParameterType::String)
82 .required(true)
83 .description("AST pattern to match. Use $VAR for wildcards, $$$VAR for variadic. e.g. 'fn $NAME($$$ARGS) -> $RET { $$$ }'")
84 .build(),
85 )
86 .parameter(
87 Parameter::builder("rewrite")
88 .param_type(ParameterType::String)
89 .required(false)
90 .description("Replacement pattern (only for action=rewrite). Use captured $VARs. e.g. '$EXPR?'")
91 .build(),
92 )
93 .parameter(
94 Parameter::builder("path")
95 .param_type(ParameterType::String)
96 .required(true)
97 .description("File or directory to search/rewrite")
98 .build(),
99 )
100 .parameter(
101 Parameter::builder("lang")
102 .param_type(ParameterType::String)
103 .required(false)
104 .description("Language: rust, python, javascript, typescript, go, c, cpp, java (default: auto-detect)")
105 .build(),
106 )
107 .build()
108 }
109
110 async fn execute(&self, args: Value) -> crate::Result<Value> {
111 let action = args["action"]
112 .as_str()
113 .ok_or_else(|| crate::PawanError::Tool("action required (search or rewrite)".into()))?;
114
115 let rewrite = match action {
116 "search" => None,
117 "rewrite" => Some(args["rewrite"].as_str().ok_or_else(|| {
118 crate::PawanError::Tool("rewrite pattern required for action=rewrite".into())
119 })?),
120 _ => {
121 return Err(crate::PawanError::Tool(format!(
122 "Unknown action: {action}. Use 'search' or 'rewrite'"
123 )));
124 }
125 };
126
127 let pattern = args["pattern"]
128 .as_str()
129 .ok_or_else(|| crate::PawanError::Tool("pattern required".into()))?;
130 let path = args["path"]
131 .as_str()
132 .ok_or_else(|| crate::PawanError::Tool("path required".into()))?;
133
134 ensure_binary("ast-grep", &self.workspace_root).await?;
135
136 let mut cmd_args: Vec<String> = vec!["run".into()];
137
138 if let Some(lang) = args["lang"].as_str() {
139 cmd_args.push("--lang".into());
140 cmd_args.push(lang.into());
141 }
142
143 cmd_args.push("--pattern".into());
144 cmd_args.push(pattern.into());
145
146 match action {
147 "search" => {
148 cmd_args.push(path.into());
149 }
150 "rewrite" => {
151 let rewrite = rewrite.expect("rewrite validated above");
152 cmd_args.push("--rewrite".into());
153 cmd_args.push(rewrite.into());
154 cmd_args.push("--update-all".into());
155 cmd_args.push(path.into());
156 }
157 _ => unreachable!("unknown actions rejected above"),
158 }
159
160 let cmd_refs: Vec<&str> = cmd_args.iter().map(|s| s.as_str()).collect();
161 let (stdout, stderr, success) = run_cmd("ast-grep", &cmd_refs, &self.workspace_root)
162 .await
163 .map_err(crate::PawanError::Tool)?;
164
165 let match_count = stdout
166 .lines()
167 .filter(|l| l.starts_with('/') || l.contains("│"))
168 .count();
169
170 Ok(json!({
171 "success": success,
172 "action": action,
173 "matches": match_count,
174 "output": stdout,
175 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
176 }))
177 }
178}
179
180pub struct LspTool {
183 workspace_root: PathBuf,
184}
185
186impl LspTool {
187 pub fn new(workspace_root: PathBuf) -> Self {
188 Self { workspace_root }
189 }
190}
191
192#[async_trait]
193impl Tool for LspTool {
194 fn name(&self) -> &str {
195 "lsp"
196 }
197
198 fn description(&self) -> &str {
199 "LSP code intelligence via rust-analyzer. Provides type-aware code understanding \
200 that grep/ast-grep can't: diagnostics without cargo check, structural search with \
201 type info, symbol extraction, and analysis stats. Actions: diagnostics (find errors), \
202 search (structural pattern search), ssr (structural search+replace with types), \
203 symbols (parse file symbols), analyze (project-wide type stats)."
204 }
205
206 fn parameters_schema(&self) -> Value {
207 json!({
208 "type": "object",
209 "properties": {
210 "action": {
211 "type": "string",
212 "enum": ["diagnostics", "search", "ssr", "symbols", "analyze"],
213 "description": "diagnostics: find errors/warnings in project. \
214 search: structural pattern search (e.g. '$a.foo($b)'). \
215 ssr: structural search+replace (e.g. '$a.unwrap() ==>> $a?'). \
216 symbols: parse file and list symbols. \
217 analyze: project-wide type analysis stats."
218 },
219 "pattern": {
220 "type": "string",
221 "description": "For search: pattern like '$a.foo($b)'. For ssr: rule like '$a.unwrap() ==>> $a?'"
222 },
223 "path": {
224 "type": "string",
225 "description": "Project path (directory with Cargo.toml) for diagnostics/analyze. File path for symbols."
226 },
227 "severity": {
228 "type": "string",
229 "enum": ["error", "warning", "info", "hint"],
230 "description": "Minimum severity for diagnostics (default: warning)"
231 }
232 },
233 "required": ["action"]
234 })
235 }
236
237 fn thulp_definition(&self) -> thulp_core::ToolDefinition {
238 use thulp_core::{Parameter, ParameterType};
239 thulp_core::ToolDefinition::builder(self.name())
240 .description(self.description())
241 .parameter(
242 Parameter::builder("action")
243 .param_type(ParameterType::String)
244 .required(true)
245 .description("diagnostics: find errors/warnings in project. \
246 search: structural pattern search (e.g. '$a.foo($b)'). \
247 ssr: structural search+replace (e.g. '$a.unwrap() ==>> $a?'). \
248 symbols: parse file and list symbols. \
249 analyze: project-wide type analysis stats.")
250 .build(),
251 )
252 .parameter(
253 Parameter::builder("pattern")
254 .param_type(ParameterType::String)
255 .required(false)
256 .description("For search: pattern like '$a.foo($b)'. For ssr: rule like '$a.unwrap() ==>> $a?'")
257 .build(),
258 )
259 .parameter(
260 Parameter::builder("path")
261 .param_type(ParameterType::String)
262 .required(false)
263 .description("Project path (directory with Cargo.toml) for diagnostics/analyze. File path for symbols.")
264 .build(),
265 )
266 .parameter(
267 Parameter::builder("severity")
268 .param_type(ParameterType::String)
269 .required(false)
270 .description("Minimum severity for diagnostics (default: warning)")
271 .build(),
272 )
273 .build()
274 }
275
276 async fn execute(&self, args: Value) -> crate::Result<Value> {
277 ensure_binary("rust-analyzer", &self.workspace_root).await?;
278
279 let action = args["action"]
280 .as_str()
281 .ok_or_else(|| crate::PawanError::Tool("action required".into()))?;
282
283 let timeout_dur = std::time::Duration::from_secs(60);
284
285 match action {
286 "diagnostics" => {
287 let path = args["path"]
288 .as_str()
289 .unwrap_or(self.workspace_root.to_str().unwrap_or("."));
290 let mut cmd_args = vec!["diagnostics", path];
291 if let Some(sev) = args["severity"].as_str() {
292 cmd_args.extend(["--severity", sev]);
293 }
294 let result = tokio::time::timeout(
295 timeout_dur,
296 run_cmd("rust-analyzer", &cmd_args, &self.workspace_root),
297 )
298 .await;
299 match result {
300 Ok(Ok((stdout, stderr, success))) => Ok(json!({
301 "success": success,
302 "diagnostics": stdout,
303 "count": stdout.lines().filter(|l| !l.is_empty()).count(),
304 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
305 })),
306 Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
307 Err(_) => Err(crate::PawanError::Tool(
308 "rust-analyzer diagnostics timed out (60s)".into(),
309 )),
310 }
311 }
312 "search" => {
313 let pattern = args["pattern"]
314 .as_str()
315 .ok_or_else(|| crate::PawanError::Tool("pattern required for search".into()))?;
316 let result = tokio::time::timeout(
317 timeout_dur,
318 run_cmd("rust-analyzer", &["search", pattern], &self.workspace_root),
319 )
320 .await;
321 match result {
322 Ok(Ok((stdout, stderr, success))) => Ok(json!({
323 "success": success,
324 "matches": stdout,
325 "count": stdout.lines().filter(|l| !l.is_empty()).count(),
326 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
327 })),
328 Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
329 Err(_) => Err(crate::PawanError::Tool(
330 "rust-analyzer search timed out (60s)".into(),
331 )),
332 }
333 }
334 "ssr" => {
335 let pattern = args["pattern"].as_str().ok_or_else(|| {
336 crate::PawanError::Tool(
337 "pattern required for ssr (format: '$a.unwrap() ==>> $a?')".into(),
338 )
339 })?;
340 let result = tokio::time::timeout(
341 timeout_dur,
342 run_cmd("rust-analyzer", &["ssr", pattern], &self.workspace_root),
343 )
344 .await;
345 match result {
346 Ok(Ok((stdout, stderr, success))) => Ok(json!({
347 "success": success,
348 "output": stdout,
349 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
350 })),
351 Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
352 Err(_) => Err(crate::PawanError::Tool(
353 "rust-analyzer ssr timed out (60s)".into(),
354 )),
355 }
356 }
357 "symbols" => {
358 let path = args["path"]
359 .as_str()
360 .ok_or_else(|| crate::PawanError::Tool("path required for symbols".into()))?;
361 let full_path = if std::path::Path::new(path).is_absolute() {
362 PathBuf::from(path)
363 } else {
364 self.workspace_root.join(path)
365 };
366 let content = tokio::fs::read_to_string(&full_path).await.map_err(|e| {
367 crate::PawanError::Tool(format!("Failed to read {}: {}", path, e))
368 })?;
369
370 let mut child = tokio::process::Command::new("rust-analyzer")
371 .arg("symbols")
372 .stdin(Stdio::piped())
373 .stdout(Stdio::piped())
374 .stderr(Stdio::piped())
375 .spawn()
376 .map_err(|e| {
377 crate::PawanError::Tool(format!("Failed to spawn rust-analyzer: {}", e))
378 })?;
379
380 if let Some(mut stdin) = child.stdin.take() {
381 use tokio::io::AsyncWriteExt;
382 let _ = stdin.write_all(content.as_bytes()).await;
383 drop(stdin);
384 }
385
386 let output = child.wait_with_output().await.map_err(|e| {
387 crate::PawanError::Tool(format!("rust-analyzer symbols failed: {}", e))
388 })?;
389
390 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
391 Ok(json!({
392 "success": output.status.success(),
393 "symbols": stdout,
394 "count": stdout.lines().filter(|l| !l.is_empty()).count()
395 }))
396 }
397 "analyze" => {
398 let path = args["path"]
399 .as_str()
400 .unwrap_or(self.workspace_root.to_str().unwrap_or("."));
401 let result = tokio::time::timeout(
402 timeout_dur,
403 run_cmd(
404 "rust-analyzer",
405 &["analysis-stats", "--skip-inference", path],
406 &self.workspace_root,
407 ),
408 )
409 .await;
410 match result {
411 Ok(Ok((stdout, stderr, success))) => Ok(json!({
412 "success": success,
413 "stats": stdout,
414 "stderr": if stderr.is_empty() { None::<String> } else { Some(stderr) }
415 })),
416 Ok(Err(e)) => Err(crate::PawanError::Tool(e)),
417 Err(_) => Err(crate::PawanError::Tool(
418 "rust-analyzer analysis-stats timed out (60s)".into(),
419 )),
420 }
421 }
422 _ => Err(crate::PawanError::Tool(format!(
423 "Unknown action: {action}. Use diagnostics/search/ssr/symbols/analyze"
424 ))),
425 }
426 }
427}
428
429#[cfg(test)]
432mod tests {
433 use super::*;
434 use tempfile::TempDir;
435
436 #[tokio::test]
437 async fn test_ast_grep_tool_schema() {
438 let tmp = TempDir::new().unwrap();
439 let tool = AstGrepTool::new(tmp.path().to_path_buf());
440 assert_eq!(tool.name(), "ast_grep");
441 let schema = tool.parameters_schema();
442 assert!(schema["properties"]["action"].is_object());
443 assert!(schema["properties"]["pattern"].is_object());
444 }
445
446 #[tokio::test]
447 async fn test_lsp_tool_schema() {
448 let tmp = TempDir::new().unwrap();
449 let tool = LspTool::new(tmp.path().to_path_buf());
450 assert_eq!(tool.name(), "lsp");
451 let schema = tool.parameters_schema();
452 assert!(schema["properties"]["action"].is_object());
453 }
454
455 #[tokio::test]
456 async fn test_lsp_missing_action() {
457 let tmp = TempDir::new().unwrap();
458 let tool = LspTool::new(tmp.path().to_path_buf());
459 let err = tool.execute(json!({})).await.unwrap_err();
460 assert!(
461 err.to_string().contains("action required"),
462 "expected action required, got: {err}"
463 );
464 }
465
466 #[tokio::test]
467 async fn test_lsp_unknown_action() {
468 let tmp = TempDir::new().unwrap();
469 let tool = LspTool::new(tmp.path().to_path_buf());
470 let err = tool.execute(json!({"action": "bogus"})).await.unwrap_err();
471 assert!(
472 err.to_string().contains("Unknown action"),
473 "expected unknown action error, got: {err}"
474 );
475 }
476
477 #[tokio::test]
478 async fn test_lsp_search_missing_pattern() {
479 let tmp = TempDir::new().unwrap();
480 let tool = LspTool::new(tmp.path().to_path_buf());
481 let err = tool.execute(json!({"action": "search"})).await.unwrap_err();
482 assert!(
483 err.to_string().contains("pattern required"),
484 "expected pattern required, got: {err}"
485 );
486 }
487
488 #[tokio::test]
489 async fn test_lsp_ssr_missing_pattern() {
490 let tmp = TempDir::new().unwrap();
491 let tool = LspTool::new(tmp.path().to_path_buf());
492 let err = tool.execute(json!({"action": "ssr"})).await.unwrap_err();
493 assert!(
494 err.to_string().contains("pattern required"),
495 "expected pattern required, got: {err}"
496 );
497 }
498
499 #[tokio::test]
500 async fn test_lsp_symbols_missing_path() {
501 let tmp = TempDir::new().unwrap();
502 let tool = LspTool::new(tmp.path().to_path_buf());
503 let err = tool
504 .execute(json!({"action": "symbols"}))
505 .await
506 .unwrap_err();
507 assert!(
508 err.to_string().contains("path required"),
509 "expected path required, got: {err}"
510 );
511 }
512
513 #[tokio::test]
514 async fn test_lsp_symbols_nonexistent_path() {
515 let tmp = TempDir::new().unwrap();
516 let tool = LspTool::new(tmp.path().to_path_buf());
517 let err = tool
518 .execute(json!({
519 "action": "symbols",
520 "path": "/tmp/nonexistent_pawan_test.rs"
521 }))
522 .await
523 .unwrap_err();
524 assert!(
525 err.to_string().contains("Failed to read"),
526 "expected read failure for missing file, got: {err}"
527 );
528 }
529
530 #[test]
531 fn test_lsp_thulp_definition() {
532 let tmp = TempDir::new().unwrap();
533 let tool = LspTool::new(tmp.path().to_path_buf());
534 let def = tool.thulp_definition();
535 assert_eq!(def.name, "lsp");
536 assert_eq!(def.parameters.len(), 4);
537 let param_names: Vec<&str> = def.parameters.iter().map(|p| p.name.as_str()).collect();
538 for name in &["action", "pattern", "path", "severity"] {
539 assert!(
540 param_names.contains(name),
541 "thulp definition missing '{name}'"
542 );
543 }
544 }
545
546 #[tokio::test]
547 async fn test_ast_grep_missing_action() {
548 let tmp = TempDir::new().unwrap();
549 let tool = AstGrepTool::new(tmp.path().to_path_buf());
550 let err = tool.execute(json!({})).await.unwrap_err();
551 assert!(
552 err.to_string().contains("action required"),
553 "expected action required, got: {err}"
554 );
555 }
556
557 #[tokio::test]
558 async fn test_ast_grep_unknown_action() {
559 let tmp = TempDir::new().unwrap();
560 let tool = AstGrepTool::new(tmp.path().to_path_buf());
561 let err = tool.execute(json!({"action": "bogus"})).await.unwrap_err();
562 assert!(
563 err.to_string().contains("Unknown"),
564 "expected unknown action error, got: {err}"
565 );
566 }
567
568 #[test]
569 fn test_ast_grep_thulp_definition() {
570 let tmp = TempDir::new().unwrap();
571 let tool = AstGrepTool::new(tmp.path().to_path_buf());
572 let def = tool.thulp_definition();
573 assert_eq!(def.name, "ast_grep");
574 assert_eq!(def.parameters.len(), 5);
575 let param_names: Vec<&str> = def.parameters.iter().map(|p| p.name.as_str()).collect();
576 for name in &["action", "pattern", "rewrite", "path", "lang"] {
577 assert!(
578 param_names.contains(name),
579 "thulp definition missing '{name}'"
580 );
581 }
582 }
583}