Skip to main content

pawan/tools/
lsp_tool.rs

1//! ast-grep and LSP (rust-analyzer) tool wrappers.
2
3use 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
10// ─── ast-grep ────────────────────────────────────────────────────────────────
11
12pub 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
180// ─── LSP (rust-analyzer powered code intelligence) ──────────────────────────
181
182pub 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// ─── tests ──────────────────────────────────────────────────────────────────
430
431#[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}