use super::{AgentTool, AgentToolResult, ToolContext, ToolError};
use async_trait::async_trait;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
use tokio::sync::oneshot;
const DEFAULT_LIMIT: usize = 50;
pub struct AstGrepTool {
root_dir: Option<PathBuf>,
}
impl AstGrepTool {
pub fn new() -> Self {
Self { root_dir: None }
}
pub fn with_cwd(cwd: PathBuf) -> Self {
Self {
root_dir: Some(cwd),
}
}
fn resolve_search_path(&self, path: &str, ctx_root: &Path) -> PathBuf {
if path.is_empty() {
ctx_root.to_path_buf()
} else {
let candidate = PathBuf::from(path);
if candidate.is_absolute() {
candidate
} else {
ctx_root.join(candidate)
}
}
}
}
impl Default for AstGrepTool {
fn default() -> Self {
Self::new()
}
}
async fn run_sg(pattern: &str, target: &Path) -> Result<Vec<Value>, String> {
let mut child = match Command::new("sg")
.arg("run")
.arg("-p")
.arg(pattern)
.arg("--json")
.arg(target)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
{
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(
"`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."
.to_string(),
);
}
Err(e) => return Err(format!("Failed to invoke `sg`: {e}")),
};
let mut stdout = child.stdout.take().expect("piped stdout");
let mut stderr = child.stderr.take().expect("piped stderr");
let mut stdout_buf = Vec::new();
let mut stderr_buf = Vec::new();
let (stdout_res, stderr_res) = tokio::join!(
stdout.read_to_end(&mut stdout_buf),
stderr.read_to_end(&mut stderr_buf)
);
stdout_res.map_err(|e| format!("Failed reading `sg` stdout: {e}"))?;
stderr_res.map_err(|e| format!("Failed reading `sg` stderr: {e}"))?;
let status = child
.wait()
.await
.map_err(|e| format!("Failed waiting on `sg`: {e}"))?;
let matches = parse_sg_output(&stdout_buf).ok_or_else(|| {
"Failed to parse `sg` JSON output: no array or stream objects found".to_string()
})?;
if !status.success() {
let stderr_text = String::from_utf8_lossy(&stderr_buf).trim().to_string();
if !stderr_text.is_empty() {
return Err(format!("`sg` failed: {stderr_text}"));
}
}
Ok(matches)
}
fn parse_sg_output(buf: &[u8]) -> Option<Vec<Value>> {
let trimmed = buf.iter().any(|b| !b.is_ascii_whitespace());
if !trimmed {
return Some(Vec::new());
}
if let Ok(v) = serde_json::from_slice::<Value>(buf) {
match v {
Value::Array(arr) => return Some(arr),
Value::Object(_) => return Some(vec![v]),
_ => {}
}
}
let mut matches = Vec::new();
let mut parsed_any = false;
for line in buf.split(|b| *b == b'\n') {
let has_content = line.iter().any(|b| !b.is_ascii_whitespace());
if !has_content {
continue;
}
match serde_json::from_slice::<Value>(line) {
Ok(v) => {
parsed_any = true;
match v {
Value::Array(arr) => matches.extend(arr),
Value::Object(_) => matches.push(v),
_ => {}
}
}
Err(_) => continue,
}
}
if parsed_any { Some(matches) } else { None }
}
fn format_matches(matches: &[Value], root: &Path) -> (String, usize) {
use std::collections::BTreeMap;
if matches.is_empty() {
return ("No matches found.".to_string(), 0);
}
let mut by_file: BTreeMap<PathBuf, Vec<(usize, usize, String)>> = BTreeMap::new();
for m in matches {
let file = m
.get("file")
.and_then(Value::as_str)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("<unknown>"));
let (line, col) = extract_position(m).unwrap_or((0, 0));
let text = m
.get("text")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_default();
let trimmed = text.lines().next().unwrap_or("").trim_end().to_string();
by_file.entry(file).or_default().push((line, col, trimmed));
}
let returned = matches.len();
let mut out = String::new();
out.push_str(&format!("Found {returned} match(es):\n"));
for (file, lines) in &by_file {
let display = file.strip_prefix(root).unwrap_or(file.as_path());
let display = display.to_string_lossy();
out.push('\n');
out.push_str(&format!("{display}\n"));
for (line, col, text) in lines {
if *col > 0 {
out.push_str(&format!(" {line}:{col}: {text}\n"));
} else {
out.push_str(&format!(" {line}: {text}\n"));
}
}
}
(out, returned)
}
fn extract_position(m: &Value) -> Option<(usize, usize)> {
if let Some(range) = m.get("range").and_then(Value::as_object) {
let start = range.get("start").and_then(Value::as_object)?;
let line = start.get("line").and_then(Value::as_u64)? as usize;
let col = start
.get("column")
.or_else(|| start.get("col"))
.and_then(Value::as_u64)
.unwrap_or(0) as usize;
return Some((line + 1, col + 1));
}
if let Some(begin) = m.get("begin").and_then(Value::as_u64) {
return Some((begin as usize + 1, 1));
}
None
}
#[async_trait]
impl AgentTool for AstGrepTool {
fn name(&self) -> &str {
"ast_grep"
}
fn label(&self) -> &str {
"AST Grep"
}
fn description(&self) -> &str {
"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."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "AST pattern in ast-grep syntax (e.g. 'fn $NAME($$$ARGS) { $$$BODY }'). Metavariables use uppercase `$NAME`; zero-or-more use `$$$NAME`."
},
"path": {
"type": "string",
"description": "File, directory, or glob to search. Defaults to the workspace root."
},
"skip": {
"type": "integer",
"description": "Number of results to skip (for pagination).",
"minimum": 0,
"default": 0
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return.",
"minimum": 1,
"default": 50
}
},
"required": ["pattern"]
})
}
async fn execute(
&self,
_tool_call_id: &str,
params: Value,
_signal: Option<oneshot::Receiver<()>>,
ctx: &ToolContext,
) -> Result<AgentToolResult, ToolError> {
let pattern = params
.get("pattern")
.and_then(Value::as_str)
.ok_or_else(|| "Missing required parameter: pattern".to_string())?
.trim();
if pattern.is_empty() {
return Ok(AgentToolResult::error(
"Invalid pattern: must be a non-empty string",
));
}
let path_arg = params.get("path").and_then(Value::as_str).unwrap_or("");
let skip = params.get("skip").and_then(Value::as_u64).unwrap_or(0) as usize;
let limit = params
.get("limit")
.and_then(Value::as_u64)
.unwrap_or(DEFAULT_LIMIT as u64) as usize;
let limit = limit.max(1);
let root = self.root_dir.as_deref().unwrap_or_else(|| ctx.root());
let search_path = self.resolve_search_path(path_arg, root);
let all_matches = match run_sg(pattern, &search_path).await {
Ok(v) => v,
Err(msg) if msg.starts_with("`sg` is not installed") => {
return Ok(AgentToolResult::error(msg));
}
Err(msg) => return Ok(AgentToolResult::error(format!("ast_grep failed: {msg}"))),
};
let total = all_matches.len();
let paged: Vec<Value> = all_matches.into_iter().skip(skip).take(limit).collect();
let returned = paged.len();
let truncated = total > skip + returned;
let (body, _returned_fmt) = format_matches(&paged, root);
let mut result = AgentToolResult::success(body);
result.metadata = Some(json!({
"total_matches": total,
"returned": returned,
"skipped": skip,
"limit": limit,
"truncated": truncated,
"pattern": pattern,
"search_path": search_path.to_string_lossy(),
}));
Ok(result)
}
}