1use std::fmt;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use regex::{Regex, RegexBuilder};
8use rig_core::completion::ToolAnnotations;
9use rig_core::tool::{
10 ContextValue, FileRead, FilesRead, Scope, Tool, ToolContext, ToolExecutionError, Touches,
11 args_schema, content_hash,
12};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize, Serializer};
15
16use crate::fs::listed;
17use crate::workspace::{Listing, Local, Workspace};
18use crate::{MAX_FILE_BYTES, blocking, clip};
19
20pub const MAX_MATCHES: usize = 200;
22pub const MAX_FILES: usize = 1000;
24const MAX_LINE: usize = 300;
25
26#[derive(Clone, Debug)]
33pub struct Search {
34 pub workspace: Arc<dyn Workspace>,
36 pub base: Option<PathBuf>,
40 pub confine: bool,
43}
44
45impl Default for Search {
46 fn default() -> Self {
48 Self {
49 workspace: Arc::new(Local),
50 base: None,
51 confine: false,
52 }
53 }
54}
55
56#[derive(Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)]
58#[serde(deny_unknown_fields)]
59pub struct SearchArgs {
60 pub pattern: String,
62 pub path: Option<String>,
64 pub glob: Option<String>,
66 #[serde(default)]
69 pub hidden: bool,
70 #[serde(default)]
72 pub files: bool,
73}
74
75impl SearchArgs {
76 pub fn new(pattern: impl Into<String>) -> Self {
78 Self {
79 pattern: pattern.into(),
80 path: None,
81 glob: None,
82 hidden: false,
83 files: false,
84 }
85 }
86
87 pub fn files(mut self) -> Self {
90 self.files = true;
91 self
92 }
93
94 pub fn in_path(mut self, path: impl Into<String>) -> Self {
96 self.path = Some(path.into());
97 self
98 }
99
100 pub fn glob(mut self, glob: impl Into<String>) -> Self {
102 self.glob = Some(glob.into());
103 self
104 }
105
106 pub fn hidden(mut self) -> Self {
108 self.hidden = true;
109 self
110 }
111
112 pub fn listing(&self) -> Listing {
114 match self.hidden {
115 true => Listing::Hidden,
116 false => Listing::Visible,
117 }
118 .for_glob(self.glob.as_deref())
119 }
120}
121
122#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
124pub struct Hit {
125 #[serde(serialize_with = "lossy")]
128 pub path: PathBuf,
129 pub line: usize,
131 pub text: String,
133}
134
135impl fmt::Display for Hit {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 write!(f, "{}:{}: {}", self.path.display(), self.line, self.text)
139 }
140}
141
142fn lossy<S: Serializer>(path: &Path, serializer: S) -> Result<S::Ok, S::Error> {
144 serializer.serialize_str(&path.to_string_lossy())
145}
146
147#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
149pub struct Found {
150 pub hits: Vec<Hit>,
153 #[serde(default, skip_serializing_if = "Vec::is_empty")]
157 pub names: Vec<PathBuf>,
158 pub stopped: bool,
161 #[serde(default)]
165 pub files: Vec<FileRead>,
166}
167
168#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(transparent)]
171pub struct SearchHits(pub Found);
172
173impl ContextValue for SearchHits {
174 const KEY: &'static str = "search_hits";
175}
176
177impl Tool for Search {
178 const NAME: &'static str = "search";
179 type Args = SearchArgs;
180 type Output = String;
181 type Error = ToolExecutionError;
182
183 fn description(&self) -> String {
184 format!(
185 "Search file contents with a regular expression (Rust regex syntax). Skips files \
186 ignored by git, target directories and, unless hidden is set or the glob names \
187 one, hidden files. Returns `path:line: text`, at most {MAX_MATCHES} matches; with \
188 files, only the paths of the files with a match, one per line, at most \
189 {MAX_FILES}, and with files and an empty pattern every file. Each path is one \
190 `read` takes: relative to the current directory when path is relative or not \
191 given, absolute when path is absolute. Use it to find code, not `grep` or `rg` in \
192 `shell`, and with files and an empty pattern to list files, not `ls` or `find`."
193 )
194 }
195
196 fn parameters(&self) -> serde_json::Value {
197 args_schema::<SearchArgs>()
198 }
199
200 fn annotations(&self) -> ToolAnnotations {
201 ToolAnnotations::default()
202 .with_read_only(true)
203 .with_open_world(false)
204 }
205
206 fn footprint(&self, args: &SearchArgs) -> Touches {
207 Touches::Reads(Scope::Paths(vec![
208 self.workspace
209 .resolve(self.base.as_deref(), args.path.as_deref().unwrap_or(".")),
210 ]))
211 }
212
213 async fn call(
214 &self,
215 context: &mut ToolContext,
216 mut args: SearchArgs,
217 ) -> Result<String, ToolExecutionError> {
218 let path = args.path.as_deref().unwrap_or(".");
219 self.workspace
220 .place(self.base.as_deref(), self.confine, path)?;
221 let base = self.base.clone().filter(|_| Path::new(path).is_relative());
222 if let Some(base) = &base {
223 let joined = self.workspace.join(base, Path::new(path));
224 args.path = Some(joined.to_string_lossy().into_owned());
225 }
226 let workspace = self.workspace.clone();
227 let mut found = blocking(move || find(&*workspace, &args)).await?;
228 if let Some(base) = &base {
229 let paths = found.hits.iter_mut().map(|hit| &mut hit.path);
230 for path in paths.chain(&mut found.names) {
231 if let Ok(below) = path.strip_prefix(base) {
232 *path = below.to_path_buf();
233 }
234 }
235 }
236 if !found.files.is_empty() {
237 context.insert_result(FilesRead(found.files.clone()))?;
238 }
239 let text = render(&found);
240 context.insert_result(SearchHits(found))?;
241 Ok(text)
242 }
243}
244
245pub fn render(found: &Found) -> String {
249 let (mut lines, stop): (Vec<String>, _) = match found.names.is_empty() {
250 true => (
251 found.hits.iter().map(Hit::to_string).collect(),
252 format!("[stopped at {MAX_MATCHES} matches]"),
253 ),
254 false => (
255 found
256 .names
257 .iter()
258 .map(|name| name.display().to_string())
259 .collect(),
260 format!("[stopped at {MAX_FILES} files; narrow the search with path or glob]"),
261 ),
262 };
263 if lines.is_empty() {
264 return "No matches.".to_owned();
265 }
266 if found.stopped {
267 lines.push(stop);
268 }
269 lines.join("\n")
270}
271
272pub fn find(workspace: &dyn Workspace, args: &SearchArgs) -> Result<Found, ToolExecutionError> {
284 let pattern = Regex::new(&args.pattern)
285 .map_err(|error| ToolExecutionError::invalid_args(format!("bad pattern: {error}")))?;
286 let whole = whole_text(&args.pattern);
287 let root = args.path.as_deref().unwrap_or(".");
288 let files = listed(
289 workspace,
290 Path::new(root),
291 args.glob.as_deref(),
292 args.listing(),
293 )?;
294 let mut found = Found::default();
295 if args.files && args.pattern.is_empty() {
296 for file in files {
297 found.names.push(walked(&file).to_path_buf());
298 if found.names.len() == MAX_FILES {
299 found.stopped = true;
300 break;
301 }
302 }
303 return Ok(found);
304 }
305 let read = files.chunks(100).flat_map(|chunk| {
306 let read = workspace.read_files(chunk, MAX_FILE_BYTES);
307 chunk.iter().zip(read)
308 });
309 for (file, bytes) in read {
310 let Some(text) = bytes.and_then(|bytes| String::from_utf8(bytes).ok()) else {
311 continue;
312 };
313 if let Some(whole) = &whole
314 && !lone_cr(&text)
315 && !whole.is_match(&text)
316 {
317 continue;
318 }
319 let path = walked(file);
320 let mut lines = text
321 .lines()
322 .enumerate()
323 .filter(|(_, line)| pattern.is_match(line))
324 .peekable();
325 if lines.peek().is_none() {
326 continue;
327 }
328 found.files.push(FileRead::new(
329 workspace.canonical(path).to_string_lossy(),
330 content_hash(text.as_bytes()),
331 ));
332 if args.files {
333 found.names.push(path.to_path_buf());
334 if found.names.len() == MAX_FILES {
335 found.stopped = true;
336 return Ok(found);
337 }
338 continue;
339 }
340 for (number, line) in lines {
341 found.hits.push(Hit {
342 path: path.to_path_buf(),
343 line: number + 1,
344 text: clip(line, MAX_LINE).to_owned(),
345 });
346 if found.hits.len() == MAX_MATCHES {
347 found.stopped = true;
348 return Ok(found);
349 }
350 }
351 }
352 Ok(found)
353}
354
355fn whole_text(pattern: &str) -> Option<Regex> {
361 let groups = pattern.split("(?").skip(1);
362 let mut flags = groups.filter_map(|group| group.split([')', ':']).next());
363 if pattern.contains("\\A") || pattern.contains("\\z") || flags.any(|it| it.contains('-')) {
364 return None;
365 }
366 RegexBuilder::new(pattern)
367 .multi_line(true)
368 .crlf(true)
369 .build()
370 .ok()
371}
372
373fn lone_cr(text: &str) -> bool {
376 text.contains('\r') && text.matches('\r').count() != text.matches("\r\n").count()
377}
378
379fn walked(path: &Path) -> &Path {
381 path.strip_prefix("./").unwrap_or(path)
382}
383
384#[cfg(test)]
385mod tests;