Skip to main content

pawan/tools/
lsp_tool.rs

1//! ast-grep and LSP (rust-analyzer) tool wrappers.
2
3use 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
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 { "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
165// ─── LSP (rust-analyzer powered code intelligence) ──────────────────────────
166
167pub 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// ─── tests ──────────────────────────────────────────────────────────────────
383
384#[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}