oxicode_agent/tools/
ast_grep.rs1use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
3use async_trait::async_trait;
4use serde_json::{Value, json};
5use std::path::{Path, PathBuf};
6use std::process::Stdio;
7use tokio::io::AsyncReadExt;
8use tokio::process::Command;
9use tokio::sync::oneshot;
10
11const DEFAULT_LIMIT: usize = 50;
13
14pub struct AstGrepTool {
16 root_dir: Option<PathBuf>,
17}
18
19impl AstGrepTool {
20 pub fn new() -> Self {
22 Self { root_dir: None }
23 }
24
25 pub fn with_cwd(cwd: PathBuf) -> Self {
27 Self {
28 root_dir: Some(cwd),
29 }
30 }
31
32 fn resolve_search_path(&self, path: &str, ctx_root: &Path) -> PathBuf {
35 if path.is_empty() {
36 ctx_root.to_path_buf()
37 } else {
38 let candidate = PathBuf::from(path);
39 if candidate.is_absolute() {
40 candidate
41 } else {
42 ctx_root.join(candidate)
43 }
44 }
45 }
46}
47
48impl Default for AstGrepTool {
49 fn default() -> Self {
50 Self::new()
51 }
52}
53
54async fn run_sg(pattern: &str, target: &Path) -> Result<Vec<Value>, String> {
68 let mut child = match Command::new("sg")
69 .arg("run")
70 .arg("-p")
71 .arg(pattern)
72 .arg("--json")
73 .arg(target)
74 .stdin(Stdio::null())
75 .stdout(Stdio::piped())
76 .stderr(Stdio::piped())
77 .spawn()
78 {
79 Ok(c) => c,
80 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
81 return Err(
82 "`sg` (ast-grep CLI) is not installed or not on PATH. Install it from https://ast-grep.github.io/ to use the ast_grep tool."
83 .to_string(),
84 );
85 }
86 Err(e) => return Err(format!("Failed to invoke `sg`: {e}")),
87 };
88
89 #[allow(clippy::expect_used)]
93 let mut stdout = child.stdout.take().expect("piped stdout");
94 #[allow(clippy::expect_used)]
95 let mut stderr = child.stderr.take().expect("piped stderr");
96
97 let mut stdout_buf = Vec::new();
98 let mut stderr_buf = Vec::new();
99 let (stdout_res, stderr_res) = tokio::join!(
100 stdout.read_to_end(&mut stdout_buf),
101 stderr.read_to_end(&mut stderr_buf)
102 );
103 stdout_res.map_err(|e| format!("Failed reading `sg` stdout: {e}"))?;
104 stderr_res.map_err(|e| format!("Failed reading `sg` stderr: {e}"))?;
105
106 let status = child
107 .wait()
108 .await
109 .map_err(|e| format!("Failed waiting on `sg`: {e}"))?;
110
111 let matches = parse_sg_output(&stdout_buf).ok_or_else(|| {
117 "Failed to parse `sg` JSON output: no array or stream objects found".to_string()
118 })?;
119
120 if !status.success() {
123 let stderr_text = String::from_utf8_lossy(&stderr_buf).trim().to_string();
124 if !stderr_text.is_empty() {
125 return Err(format!("`sg` failed: {stderr_text}"));
126 }
127 }
128
129 Ok(matches)
130}
131
132fn parse_sg_output(buf: &[u8]) -> Option<Vec<Value>> {
148 let trimmed = buf.iter().any(|b| !b.is_ascii_whitespace());
149 if !trimmed {
150 return Some(Vec::new());
151 }
152
153 if let Ok(v) = serde_json::from_slice::<Value>(buf) {
155 match v {
156 Value::Array(arr) => return Some(arr),
157 Value::Object(_) => return Some(vec![v]),
158 _ => {}
159 }
160 }
161
162 let mut matches = Vec::new();
164 let mut parsed_any = false;
165 for line in buf.split(|b| *b == b'\n') {
166 let has_content = line.iter().any(|b| !b.is_ascii_whitespace());
167 if !has_content {
168 continue;
169 }
170 match serde_json::from_slice::<Value>(line) {
171 Ok(v) => {
172 parsed_any = true;
173 match v {
174 Value::Array(arr) => matches.extend(arr),
175 Value::Object(_) => matches.push(v),
178 _ => {}
179 }
180 }
181 Err(_) => continue,
182 }
183 }
184
185 if parsed_any { Some(matches) } else { None }
186}
187
188fn format_matches(matches: &[Value], root: &Path) -> (String, usize) {
199 use std::collections::BTreeMap;
200
201 if matches.is_empty() {
202 return ("No matches found.".to_string(), 0);
203 }
204
205 let mut by_file: BTreeMap<PathBuf, Vec<(usize, usize, String)>> = BTreeMap::new();
207
208 for m in matches {
209 let file = m
210 .get("file")
211 .and_then(Value::as_str)
212 .map(PathBuf::from)
213 .unwrap_or_else(|| PathBuf::from("<unknown>"));
214
215 let (line, col) = extract_position(m).unwrap_or((0, 0));
218
219 let text = m
220 .get("text")
221 .and_then(Value::as_str)
222 .map(str::to_string)
223 .unwrap_or_default();
224
225 let trimmed = text.lines().next().unwrap_or("").trim_end().to_string();
227
228 by_file.entry(file).or_default().push((line, col, trimmed));
229 }
230
231 let returned = matches.len();
232 let mut out = String::new();
233 out.push_str(&format!("Found {returned} match(es):\n"));
234
235 for (file, lines) in &by_file {
236 let display = file.strip_prefix(root).unwrap_or(file.as_path());
237 let display = display.to_string_lossy();
238 out.push('\n');
239 out.push_str(&format!("{display}\n"));
240 for (line, col, text) in lines {
241 if *col > 0 {
242 out.push_str(&format!(" {line}:{col}: {text}\n"));
243 } else {
244 out.push_str(&format!(" {line}: {text}\n"));
245 }
246 }
247 }
248
249 (out, returned)
250}
251
252fn extract_position(m: &Value) -> Option<(usize, usize)> {
257 if let Some(range) = m.get("range").and_then(Value::as_object) {
258 let start = range.get("start").and_then(Value::as_object)?;
259 let line = start.get("line").and_then(Value::as_u64)? as usize;
260 let col = start
261 .get("column")
262 .or_else(|| start.get("col"))
263 .and_then(Value::as_u64)
264 .unwrap_or(0) as usize;
265 return Some((line + 1, col + 1));
266 }
267
268 if let Some(begin) = m.get("begin").and_then(Value::as_u64) {
269 return Some((begin as usize + 1, 1));
270 }
271
272 None
273}
274
275#[async_trait]
276impl AgentTool for AstGrepTool {
277 fn name(&self) -> &str {
278 "ast_grep"
279 }
280
281 fn label(&self) -> &str {
282 "AST Grep"
283 }
284
285 fn description(&self) -> &str {
286 "Structural code search using ast-grep. Pattern uses ast-grep pattern syntax (e.g. 'fn $NAME($$$ARGS) { $$$BODY }'). Runs `sg run -p <pattern> --json <path>` and groups results by file with line numbers. Requires the `sg` (ast-grep) CLI to be installed."
287 }
288
289 fn parameters_schema(&self) -> Value {
290 json!({
291 "type": "object",
292 "properties": {
293 "pattern": {
294 "type": "string",
295 "description": "AST pattern in ast-grep syntax (e.g. 'fn $NAME($$$ARGS) { $$$BODY }'). Metavariables use uppercase `$NAME`; zero-or-more use `$$$NAME`."
296 },
297 "path": {
298 "type": "string",
299 "description": "File, directory, or glob to search. Defaults to the workspace root."
300 },
301 "skip": {
302 "type": "integer",
303 "description": "Number of results to skip (for pagination).",
304 "minimum": 0,
305 "default": 0
306 },
307 "limit": {
308 "type": "integer",
309 "description": "Maximum number of results to return.",
310 "minimum": 1,
311 "default": 50
312 }
313 },
314 "required": ["pattern"]
315 })
316 }
317
318 async fn execute(
319 &self,
320 _tool_call_id: &str,
321 params: Value,
322 _signal: Option<oneshot::Receiver<()>>,
323 ctx: &ToolContext,
324 ) -> Result<AgentToolResult, ToolError> {
325 let pattern = params
327 .get("pattern")
328 .and_then(Value::as_str)
329 .ok_or_else(|| "Missing required parameter: pattern".to_string())?
330 .trim();
331
332 if pattern.is_empty() {
333 return Ok(AgentToolResult::error(
334 "Invalid pattern: must be a non-empty string",
335 ));
336 }
337
338 let path_arg = params.get("path").and_then(Value::as_str).unwrap_or("");
340
341 let skip = params.get("skip").and_then(Value::as_u64).unwrap_or(0) as usize;
342
343 let limit = params
344 .get("limit")
345 .and_then(Value::as_u64)
346 .unwrap_or(DEFAULT_LIMIT as u64) as usize;
347 let limit = limit.max(1);
348
349 let root = self.root_dir.as_deref().unwrap_or_else(|| ctx.root());
350 let search_path = self.resolve_search_path(path_arg, root);
351
352 let all_matches = match run_sg(pattern, &search_path).await {
354 Ok(v) => v,
355 Err(msg) if msg.starts_with("`sg` is not installed") => {
356 return Ok(AgentToolResult::error(msg));
357 }
358 Err(msg) => return Ok(AgentToolResult::error(format!("ast_grep failed: {msg}"))),
359 };
360
361 let total = all_matches.len();
362
363 let paged: Vec<Value> = all_matches.into_iter().skip(skip).take(limit).collect();
369 let returned = paged.len();
370 let truncated = total > skip + returned;
371
372 let (body, _returned_fmt) = format_matches(&paged, root);
374 let mut result = AgentToolResult::success(body);
375 result.metadata = Some(json!({
376 "total_matches": total,
377 "returned": returned,
378 "skipped": skip,
379 "limit": limit,
380 "truncated": truncated,
381 "pattern": pattern,
382 "search_path": search_path.to_string_lossy(),
383 }));
384
385 Ok(result)
386 }
387}