use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use regex::{Regex, RegexBuilder};
use rig_core::completion::ToolAnnotations;
use rig_core::tool::{
ContextValue, FileRead, FilesRead, Scope, Tool, ToolContext, ToolExecutionError, Touches,
args_schema, content_hash,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize, Serializer};
use crate::fs::listed;
use crate::workspace::{Listing, Local, Workspace};
use crate::{MAX_FILE_BYTES, blocking, clip};
pub const MAX_MATCHES: usize = 200;
pub const MAX_FILES: usize = 1000;
const MAX_LINE: usize = 300;
#[derive(Clone, Debug)]
pub struct Search {
pub workspace: Arc<dyn Workspace>,
pub base: Option<PathBuf>,
pub confine: bool,
}
impl Default for Search {
fn default() -> Self {
Self {
workspace: Arc::new(Local),
base: None,
confine: false,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SearchArgs {
pub pattern: String,
pub path: Option<String>,
pub glob: Option<String>,
#[serde(default)]
pub hidden: bool,
#[serde(default)]
pub files: bool,
}
impl SearchArgs {
pub fn new(pattern: impl Into<String>) -> Self {
Self {
pattern: pattern.into(),
path: None,
glob: None,
hidden: false,
files: false,
}
}
pub fn files(mut self) -> Self {
self.files = true;
self
}
pub fn in_path(mut self, path: impl Into<String>) -> Self {
self.path = Some(path.into());
self
}
pub fn glob(mut self, glob: impl Into<String>) -> Self {
self.glob = Some(glob.into());
self
}
pub fn hidden(mut self) -> Self {
self.hidden = true;
self
}
pub fn listing(&self) -> Listing {
match self.hidden {
true => Listing::Hidden,
false => Listing::Visible,
}
.for_glob(self.glob.as_deref())
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Hit {
#[serde(serialize_with = "lossy")]
pub path: PathBuf,
pub line: usize,
pub text: String,
}
impl fmt::Display for Hit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}: {}", self.path.display(), self.line, self.text)
}
}
fn lossy<S: Serializer>(path: &Path, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&path.to_string_lossy())
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Found {
pub hits: Vec<Hit>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub names: Vec<PathBuf>,
pub stopped: bool,
#[serde(default)]
pub files: Vec<FileRead>,
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SearchHits(pub Found);
impl ContextValue for SearchHits {
const KEY: &'static str = "search_hits";
}
impl Tool for Search {
const NAME: &'static str = "search";
type Args = SearchArgs;
type Output = String;
type Error = ToolExecutionError;
fn description(&self) -> String {
format!(
"Search file contents with a regular expression (Rust regex syntax). Skips files \
ignored by git, target directories and, unless hidden is set or the glob names \
one, hidden files. Returns `path:line: text`, at most {MAX_MATCHES} matches; with \
files, only the paths of the files with a match, one per line, at most \
{MAX_FILES}, and with files and an empty pattern every file. Each path is one \
`read` takes: relative to the current directory when path is relative or not \
given, absolute when path is absolute. Use it to find code, not `grep` or `rg` in \
`shell`, and with files and an empty pattern to list files, not `ls` or `find`."
)
}
fn parameters(&self) -> serde_json::Value {
args_schema::<SearchArgs>()
}
fn annotations(&self) -> ToolAnnotations {
ToolAnnotations::default()
.with_read_only(true)
.with_open_world(false)
}
fn footprint(&self, args: &SearchArgs) -> Touches {
Touches::Reads(Scope::Paths(vec![
self.workspace
.resolve(self.base.as_deref(), args.path.as_deref().unwrap_or(".")),
]))
}
async fn call(
&self,
context: &mut ToolContext,
mut args: SearchArgs,
) -> Result<String, ToolExecutionError> {
let path = args.path.as_deref().unwrap_or(".");
self.workspace
.place(self.base.as_deref(), self.confine, path)?;
let base = self.base.clone().filter(|_| Path::new(path).is_relative());
if let Some(base) = &base {
let joined = self.workspace.join(base, Path::new(path));
args.path = Some(joined.to_string_lossy().into_owned());
}
let workspace = self.workspace.clone();
let mut found = blocking(move || find(&*workspace, &args)).await?;
if let Some(base) = &base {
let paths = found.hits.iter_mut().map(|hit| &mut hit.path);
for path in paths.chain(&mut found.names) {
if let Ok(below) = path.strip_prefix(base) {
*path = below.to_path_buf();
}
}
}
if !found.files.is_empty() {
context.insert_result(FilesRead(found.files.clone()))?;
}
let text = render(&found);
context.insert_result(SearchHits(found))?;
Ok(text)
}
}
pub fn render(found: &Found) -> String {
let (mut lines, stop): (Vec<String>, _) = match found.names.is_empty() {
true => (
found.hits.iter().map(Hit::to_string).collect(),
format!("[stopped at {MAX_MATCHES} matches]"),
),
false => (
found
.names
.iter()
.map(|name| name.display().to_string())
.collect(),
format!("[stopped at {MAX_FILES} files; narrow the search with path or glob]"),
),
};
if lines.is_empty() {
return "No matches.".to_owned();
}
if found.stopped {
lines.push(stop);
}
lines.join("\n")
}
pub fn find(workspace: &dyn Workspace, args: &SearchArgs) -> Result<Found, ToolExecutionError> {
let pattern = Regex::new(&args.pattern)
.map_err(|error| ToolExecutionError::invalid_args(format!("bad pattern: {error}")))?;
let whole = whole_text(&args.pattern);
let root = args.path.as_deref().unwrap_or(".");
let files = listed(
workspace,
Path::new(root),
args.glob.as_deref(),
args.listing(),
)?;
let mut found = Found::default();
if args.files && args.pattern.is_empty() {
for file in files {
found.names.push(walked(&file).to_path_buf());
if found.names.len() == MAX_FILES {
found.stopped = true;
break;
}
}
return Ok(found);
}
let read = files.chunks(100).flat_map(|chunk| {
let read = workspace.read_files(chunk, MAX_FILE_BYTES);
chunk.iter().zip(read)
});
for (file, bytes) in read {
let Some(text) = bytes.and_then(|bytes| String::from_utf8(bytes).ok()) else {
continue;
};
if let Some(whole) = &whole
&& !lone_cr(&text)
&& !whole.is_match(&text)
{
continue;
}
let path = walked(file);
let mut lines = text
.lines()
.enumerate()
.filter(|(_, line)| pattern.is_match(line))
.peekable();
if lines.peek().is_none() {
continue;
}
found.files.push(FileRead::new(
workspace.canonical(path).to_string_lossy(),
content_hash(text.as_bytes()),
));
if args.files {
found.names.push(path.to_path_buf());
if found.names.len() == MAX_FILES {
found.stopped = true;
return Ok(found);
}
continue;
}
for (number, line) in lines {
found.hits.push(Hit {
path: path.to_path_buf(),
line: number + 1,
text: clip(line, MAX_LINE).to_owned(),
});
if found.hits.len() == MAX_MATCHES {
found.stopped = true;
return Ok(found);
}
}
}
Ok(found)
}
fn whole_text(pattern: &str) -> Option<Regex> {
let groups = pattern.split("(?").skip(1);
let mut flags = groups.filter_map(|group| group.split([')', ':']).next());
if pattern.contains("\\A") || pattern.contains("\\z") || flags.any(|it| it.contains('-')) {
return None;
}
RegexBuilder::new(pattern)
.multi_line(true)
.crlf(true)
.build()
.ok()
}
fn lone_cr(text: &str) -> bool {
text.contains('\r') && text.matches('\r').count() != text.matches("\r\n").count()
}
fn walked(path: &Path) -> &Path {
path.strip_prefix("./").unwrap_or(path)
}
#[cfg(test)]
mod tests;