Paginate regex and path search tools (#26997)

<img width="630" alt="Screenshot 2025-03-18 at 10 50 17 AM"
src="https://github.com/user-attachments/assets/0aee5367-402a-405a-8676-f2f8af425b1e"
/>

Release Notes:

- N/A
This commit is contained in:
Richard Feldman 2025-03-18 11:44:41 -04:00 committed by GitHub
parent 41a2be7e54
commit 06e9f0e309
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 101 additions and 26 deletions

View File

@ -23,8 +23,15 @@ pub struct PathSearchToolInput {
/// You can get back the first two paths by providing a glob of "*thing*.txt" /// You can get back the first two paths by providing a glob of "*thing*.txt"
/// </example> /// </example>
pub glob: String, pub glob: String,
/// Optional starting position for paginated results (0-based).
/// When not provided, starts from the beginning.
#[serde(default)]
pub offset: Option<usize>,
} }
const RESULTS_PER_PAGE: usize = 50;
pub struct PathSearchTool; pub struct PathSearchTool;
impl Tool for PathSearchTool { impl Tool for PathSearchTool {
@ -49,8 +56,8 @@ impl Tool for PathSearchTool {
_action_log: Entity<ActionLog>, _action_log: Entity<ActionLog>,
cx: &mut App, cx: &mut App,
) -> Task<Result<String>> { ) -> Task<Result<String>> {
let glob = match serde_json::from_value::<PathSearchToolInput>(input) { let (offset, glob) = match serde_json::from_value::<PathSearchToolInput>(input) {
Ok(input) => input.glob, Ok(input) => (input.offset.unwrap_or(0), input.glob),
Err(err) => return Task::ready(Err(anyhow!(err))), Err(err) => return Task::ready(Err(anyhow!(err))),
}; };
let path_matcher = match PathMatcher::new(&[glob.clone()]) { let path_matcher = match PathMatcher::new(&[glob.clone()]) {
@ -87,7 +94,27 @@ impl Tool for PathSearchTool {
} else { } else {
// Sort to group entries in the same directory together. // Sort to group entries in the same directory together.
matches.sort(); matches.sort();
Ok(matches.join("\n"))
let total_matches = matches.len();
let response = if total_matches > offset + RESULTS_PER_PAGE {
let paginated_matches: Vec<_> = matches
.into_iter()
.skip(offset)
.take(RESULTS_PER_PAGE)
.collect();
format!(
"Found {} total matches. Showing results {}-{} (provide 'offset' parameter for more results):\n\n{}",
total_matches,
offset + 1,
offset + paginated_matches.len(),
paginated_matches.join("\n")
)
} else {
matches.join("\n")
};
Ok(response)
} }
}) })
} }

View File

@ -1 +1,3 @@
Returns all the paths in the project which match the given glob. Returns paths in the project which match the given glob.
Results are paginated with 50 matches per page. Use the optional 'offset' parameter to request subsequent pages.

View File

@ -28,13 +28,13 @@ pub struct ReadFileToolInput {
/// </example> /// </example>
pub path: Arc<Path>, pub path: Arc<Path>,
/// Optional line number to start reading from (0-based index) /// Optional line number to start reading on (1-based index)
#[serde(default)] #[serde(default)]
pub start_line: Option<usize>, pub start_line: Option<usize>,
/// Optional number of lines to read /// Optional line number to end reading on (1-based index)
#[serde(default)] #[serde(default)]
pub line_count: Option<usize>, pub end_line: Option<usize>,
} }
pub struct ReadFileTool; pub struct ReadFileTool;
@ -83,10 +83,12 @@ impl Tool for ReadFileTool {
.map_or(false, |file| file.disk_state().exists()) .map_or(false, |file| file.disk_state().exists())
{ {
let text = buffer.text(); let text = buffer.text();
let string = if input.start_line.is_some() || input.line_count.is_some() { let string = if input.start_line.is_some() || input.end_line.is_some() {
let lines = text.split('\n').skip(input.start_line.unwrap_or(0)); let start = input.start_line.unwrap_or(1);
if let Some(line_count) = input.line_count { let lines = text.split('\n').skip(start - 1);
Itertools::intersperse(lines.take(line_count), "\n").collect() if let Some(end) = input.end_line {
let count = end.saturating_sub(start);
Itertools::intersperse(lines.take(count), "\n").collect()
} else { } else {
Itertools::intersperse(lines, "\n").collect() Itertools::intersperse(lines, "\n").collect()
} }

View File

@ -4,7 +4,10 @@ use futures::StreamExt;
use gpui::{App, Entity, Task}; use gpui::{App, Entity, Task};
use language::OffsetRangeExt; use language::OffsetRangeExt;
use language_model::LanguageModelRequestMessage; use language_model::LanguageModelRequestMessage;
use project::{search::SearchQuery, Project}; use project::{
search::{SearchQuery, SearchResult},
Project,
};
use schemars::JsonSchema; use schemars::JsonSchema;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{cmp, fmt::Write, sync::Arc}; use std::{cmp, fmt::Write, sync::Arc};
@ -15,8 +18,15 @@ pub struct RegexSearchToolInput {
/// A regex pattern to search for in the entire project. Note that the regex /// A regex pattern to search for in the entire project. Note that the regex
/// will be parsed by the Rust `regex` crate. /// will be parsed by the Rust `regex` crate.
pub regex: String, pub regex: String,
/// Optional starting position for paginated results (0-based).
/// When not provided, starts from the beginning.
#[serde(default)]
pub offset: Option<usize>,
} }
const RESULTS_PER_PAGE: usize = 20;
pub struct RegexSearchTool; pub struct RegexSearchTool;
impl Tool for RegexSearchTool { impl Tool for RegexSearchTool {
@ -43,13 +53,13 @@ impl Tool for RegexSearchTool {
) -> Task<Result<String>> { ) -> Task<Result<String>> {
const CONTEXT_LINES: u32 = 2; const CONTEXT_LINES: u32 = 2;
let input = match serde_json::from_value::<RegexSearchToolInput>(input) { let (offset, regex) = match serde_json::from_value::<RegexSearchToolInput>(input) {
Ok(input) => input, Ok(input) => (input.offset.unwrap_or(0), input.regex),
Err(err) => return Task::ready(Err(anyhow!(err))), Err(err) => return Task::ready(Err(anyhow!(err))),
}; };
let query = match SearchQuery::regex( let query = match SearchQuery::regex(
&input.regex, &regex,
false, false,
false, false,
false, false,
@ -62,20 +72,23 @@ impl Tool for RegexSearchTool {
}; };
let results = project.update(cx, |project, cx| project.search(query, cx)); let results = project.update(cx, |project, cx| project.search(query, cx));
cx.spawn(|cx| async move { cx.spawn(|cx| async move {
futures::pin_mut!(results); futures::pin_mut!(results);
let mut output = String::new(); let mut output = String::new();
while let Some(project::search::SearchResult::Buffer { buffer, ranges }) = let mut skips_remaining = offset;
results.next().await let mut matches_found = 0;
{ let mut has_more_matches = false;
while let Some(SearchResult::Buffer { buffer, ranges }) = results.next().await {
if ranges.is_empty() { if ranges.is_empty() {
continue; continue;
} }
buffer.read_with(&cx, |buffer, cx| { buffer.read_with(&cx, |buffer, cx| -> Result<(), anyhow::Error> {
if let Some(path) = buffer.file().map(|file| file.full_path(cx)) { if let Some(path) = buffer.file().map(|file| file.full_path(cx)) {
writeln!(output, "### Found matches in {}:\n", path.display()).unwrap(); let mut file_header_written = false;
let mut ranges = ranges let mut ranges = ranges
.into_iter() .into_iter()
.map(|range| { .map(|range| {
@ -93,6 +106,17 @@ impl Tool for RegexSearchTool {
.peekable(); .peekable();
while let Some(mut range) = ranges.next() { while let Some(mut range) = ranges.next() {
if skips_remaining > 0 {
skips_remaining -= 1;
continue;
}
// We'd already found a full page of matches, and we just found one more.
if matches_found >= RESULTS_PER_PAGE {
has_more_matches = true;
return Ok(());
}
while let Some(next_range) = ranges.peek() { while let Some(next_range) = ranges.peek() {
if range.end.row >= next_range.start.row { if range.end.row >= next_range.start.row {
range.end = next_range.end; range.end = next_range.end;
@ -102,18 +126,36 @@ impl Tool for RegexSearchTool {
} }
} }
writeln!(output, "```").unwrap(); if !file_header_written {
output.extend(buffer.text_for_range(range)); writeln!(output, "\n## Matches in {}", path.display())?;
writeln!(output, "\n```\n").unwrap(); file_header_written = true;
}
}
})?;
} }
if output.is_empty() { let start_line = range.start.row + 1;
let end_line = range.end.row + 1;
writeln!(output, "\n### Lines {start_line}-{end_line}\n```")?;
output.extend(buffer.text_for_range(range));
output.push_str("\n```\n");
matches_found += 1;
}
}
Ok(())
})??;
}
if matches_found == 0 {
Ok("No matches found".to_string()) Ok("No matches found".to_string())
} else if has_more_matches {
Ok(format!(
"Showing matches {}-{} (there were more matches found; use offset: {} to see next page):\n{output}",
offset + 1,
offset + matches_found,
offset + RESULTS_PER_PAGE,
))
} else { } else {
Ok(output) Ok(format!("Found {matches_found} matches:\n{output}"))
} }
}) })
} }

View File

@ -1,3 +1,5 @@
Searches the entire project for the given regular expression. Searches the entire project for the given regular expression.
Returns a list of paths that matched the query. For each path, it returns a list of excerpts of the matched text. Returns a list of paths that matched the query. For each path, it returns a list of excerpts of the matched text.
Results are paginated with 20 matches per page. Use the optional 'offset' parameter to request subsequent pages.