1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::process::Stdio;
6
7use futures_util::future::BoxFuture;
8use serde_json::{Map, Value, json};
9use tokio::io::AsyncBufReadExt;
10use tokio_util::sync::CancellationToken;
11use yapi_agent::{Tool, UpdateSink};
12use yapi_types::event::ToolResult;
13use yapi_types::message::ToolDeclaration;
14
15use super::external::{ExternalTool, ensure_tool};
16use super::path::{relative, resolve_to_cwd};
17use super::truncate::{DEFAULT_MAX_BYTES, GREP_MAX_LINE_LENGTH, truncate_line};
18use super::{ToolEnv, capped_output, check_abort, js_number, plain_declaration, text_result};
19
20const DEFAULT_LIMIT: f64 = 100.0;
21
22pub struct Grep {
24 env: ToolEnv,
25 declaration: ToolDeclaration,
26}
27
28impl Grep {
29 pub fn new(env: ToolEnv) -> Grep {
31 Grep {
32 env,
33 declaration: plain_declaration(
34 "grep",
35 format!(
36 "Search file contents for a pattern. Returns matching lines with file paths and line numbers. Respects .gitignore. Output is truncated to {} matches or {}KB (whichever is hit first). Long lines are truncated to {GREP_MAX_LINE_LENGTH} chars.",
37 DEFAULT_LIMIT,
38 DEFAULT_MAX_BYTES / 1024
39 ),
40 json!({"type":"object","required":["pattern"],"properties":{
41 "pattern":{"type":"string","description":"Search pattern (regex or literal string)"},
42 "path":{"type":"string","description":"Directory or file to search (default: current directory)"},
43 "glob":{"type":"string","description":"Filter files by glob pattern, e.g. '*.ts' or '**/*.spec.ts'"},
44 "ignoreCase":{"type":"boolean","description":"Case-insensitive search (default: false)"},
45 "literal":{"type":"boolean","description":"Treat pattern as literal string instead of regex (default: false)"},
46 "context":{"type":"number","description":"Number of lines to show before and after each match (default: 0)"},
47 "limit":{"type":"number","description":"Maximum number of matches to return (default: 100)"}}}),
48 ),
49 }
50 }
51}
52
53impl Tool for Grep {
54 fn declaration(&self) -> &ToolDeclaration {
55 &self.declaration
56 }
57
58 fn execute(
59 &self,
60 _call_id: String,
61 args: Value,
62 cancel: CancellationToken,
63 _updates: UpdateSink,
64 ) -> BoxFuture<'_, Result<ToolResult, String>> {
65 Box::pin(async move {
66 check_abort(&cancel)?;
67 self.search(args, cancel).await
68 })
69 }
70}
71
72struct Match {
73 file: String,
74 line: u64,
75 text: Option<String>,
76}
77
78fn file_lines(path: &Path) -> Vec<String> {
80 match std::fs::read(path) {
81 Ok(bytes) => String::from_utf8_lossy(&bytes)
82 .replace("\r\n", "\n")
83 .replace('\r', "\n")
84 .split('\n')
85 .map(str::to_owned)
86 .collect(),
87 Err(_) => Vec::new(),
88 }
89}
90
91impl Grep {
92 async fn search(&self, args: Value, cancel: CancellationToken) -> Result<ToolResult, String> {
93 let Some(rg) = ensure_tool(ExternalTool::Rg, &self.env.bin_dir).await else {
94 return Err("ripgrep (rg) is not available and could not be downloaded".into());
95 };
96 let pattern = args["pattern"].as_str().unwrap_or_default();
97 let dir = args["path"]
98 .as_str()
99 .filter(|p| !p.is_empty())
100 .unwrap_or(".");
101 let search = resolve_to_cwd(dir, &self.env.cwd);
102 let is_directory = match std::fs::metadata(&search) {
103 Ok(metadata) => metadata.is_dir(),
104 Err(_) => return Err(format!("Path not found: {}", search.display())),
105 };
106 let context = args["context"].as_f64().filter(|c| *c > 0.0).unwrap_or(0.0) as u64;
107 let limit = args["limit"].as_f64().unwrap_or(DEFAULT_LIMIT).max(1.0);
108 let format_path = |file: &str| -> String {
109 let file = Path::new(file);
110 if is_directory {
111 let relative = relative(&search, file);
112 if !relative.is_empty() && !relative.starts_with("..") {
113 return relative.replace('\\', "/");
114 }
115 }
116 file.file_name()
117 .map(|name| name.to_string_lossy().into_owned())
118 .unwrap_or_default()
119 };
120
121 let mut command = tokio::process::Command::new(&rg);
122 command.args(["--json", "--line-number", "--color=never", "--hidden"]);
123 if args["ignoreCase"] == true {
124 command.arg("--ignore-case");
125 }
126 if args["literal"] == true {
127 command.arg("--fixed-strings");
128 }
129 if let Some(glob) = args["glob"].as_str().filter(|g| !g.is_empty()) {
130 command.args(["--glob", glob]);
131 }
132 command
133 .arg("--")
134 .arg(pattern)
135 .arg(&search)
136 .stdin(Stdio::null())
137 .stdout(Stdio::piped())
138 .stderr(Stdio::piped())
139 .kill_on_drop(true);
140 let mut child = command
141 .spawn()
142 .map_err(|err| format!("Failed to run ripgrep: {err}"))?;
143 let stdout = child.stdout.take();
144 let stderr = child.stderr.take();
145 let stderr_task = tokio::spawn(async move {
146 let mut text = String::new();
147 if let Some(mut stderr) = stderr {
148 use tokio::io::AsyncReadExt;
149 let mut bytes = Vec::new();
150 let _ = stderr.read_to_end(&mut bytes).await;
151 text = String::from_utf8_lossy(&bytes).into_owned();
152 }
153 text
154 });
155
156 let mut matches: Vec<Match> = Vec::new();
157 let mut limit_reached = false;
158 if let Some(stdout) = stdout {
159 let mut lines = tokio::io::BufReader::new(stdout).lines();
160 loop {
161 let line = tokio::select! {
162 () = cancel.cancelled() => {
163 let _ = child.kill().await;
164 return Err("Operation aborted".into());
165 }
166 line = lines.next_line() => line,
167 };
168 let Ok(Some(line)) = line else { break };
169 if line.trim().is_empty() || matches.len() as f64 >= limit {
170 continue;
171 }
172 let Ok(event) = serde_json::from_str::<Value>(&line) else {
173 continue;
174 };
175 if event["type"] != "match" {
176 continue;
177 }
178 let data = &event["data"];
179 if let (Some(file), Some(number)) =
180 (data["path"]["text"].as_str(), data["line_number"].as_u64())
181 {
182 matches.push(Match {
183 file: file.to_owned(),
184 line: number,
185 text: data["lines"]["text"].as_str().map(str::to_owned),
186 });
187 }
188 if matches.len() as f64 >= limit {
189 limit_reached = true;
190 let _ = child.start_kill();
191 }
192 }
193 }
194 let status = child.wait().await;
195 let stderr = stderr_task.await.unwrap_or_default();
196 check_abort(&cancel)?;
197 if !limit_reached {
198 let code = status.ok().and_then(|status| status.code());
199 if !matches!(code, Some(0 | 1)) {
200 let message = stderr.trim();
201 return Err(if message.is_empty() {
202 format!(
203 "ripgrep exited with code {}",
204 code.map_or("null".to_owned(), |c| c.to_string())
205 )
206 } else {
207 message.to_owned()
208 });
209 }
210 }
211 if matches.is_empty() {
212 return Ok(text_result("No matches found", None));
213 }
214
215 let mut lines_truncated = false;
216 let mut cache: HashMap<String, Vec<String>> = HashMap::new();
217 let mut output: Vec<String> = Vec::new();
218 for found in &matches {
219 let shown = format_path(&found.file);
220 match (&found.text, context) {
221 (Some(text), 0) => {
222 let text = text.replace("\r\n", "\n").replace('\r', "");
223 let text = text.strip_suffix('\n').unwrap_or(&text);
224 let (text, cut) = truncate_line(text, GREP_MAX_LINE_LENGTH);
225 lines_truncated |= cut;
226 output.push(format!("{shown}:{}: {text}", found.line));
227 }
228 _ => {
229 let lines = cache
230 .entry(found.file.clone())
231 .or_insert_with(|| file_lines(&PathBuf::from(&found.file)));
232 if lines.is_empty() {
233 output.push(format!("{shown}:{}: (unable to read file)", found.line));
234 continue;
235 }
236 let (start, end) = if context > 0 {
237 (
238 found.line.saturating_sub(context).max(1),
239 found.line.saturating_add(context).min(lines.len() as u64),
240 )
241 } else {
242 (found.line, found.line)
243 };
244 for current in start..=end {
245 let text = lines
246 .get(current as usize - 1)
247 .map(|line| line.replace('\r', ""))
248 .unwrap_or_default();
249 let (text, cut) = truncate_line(&text, GREP_MAX_LINE_LENGTH);
250 lines_truncated |= cut;
251 if current == found.line {
252 output.push(format!("{shown}:{current}: {text}"));
253 } else {
254 output.push(format!("{shown}-{current}- {text}"));
255 }
256 }
257 }
258 }
259 }
260
261 let mut details = Map::new();
262 let mut notices = Vec::new();
263 if limit_reached {
264 notices.push(format!(
265 "{} matches limit reached. Use limit={} for more, or refine pattern",
266 js_number(limit),
267 js_number(limit * 2.0)
268 ));
269 details.insert("matchLimitReached".into(), json!(limit));
270 }
271 let after = if lines_truncated {
272 vec![format!(
273 "Some lines truncated to {GREP_MAX_LINE_LENGTH} chars. Use read tool to see full lines"
274 )]
275 } else {
276 Vec::new()
277 };
278 let mut result = capped_output(&output.join("\n"), notices, after, details);
279 if lines_truncated {
280 result.details.get_or_insert_with(|| json!({}))["linesTruncated"] = json!(true);
281 }
282 Ok(result)
283 }
284}