返回 CodeWhale
search.rs
根目录 / crates / tui / src / tools / search.rs
1 //! Search tools: `grep_files` for code search
2 //!
3 //! These tools provide powerful code search capabilities within the workspace,
4 //! similar to ripgrep/grep functionality.
5
6 use super::file::{
7 PATH_ALIASES, SEARCH_CONTENT_ALIASES, SEARCH_CONTENT_PARAMS, apply_param_aliases,
8 };
9 use super::spec::{
10 ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_bool, optional_str,
11 optional_u64, required_str,
12 };
13 use async_trait::async_trait;
14 use regex::Regex;
15 use serde::{Deserialize, Serialize};
16 use serde_json::{Value, json};
17 use std::collections::{HashSet, VecDeque};
18 use std::fs;
19 use std::io::BufRead;
20 use std::path::{Path, PathBuf};
21 use std::time::Duration;
22 use tokio_util::sync::CancellationToken;
23
24 /// Maximum number of results to return to avoid overwhelming output
25 const MAX_RESULTS: usize = 100;
26
27 /// Maximum file size to search (skip large binaries)
28 const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; // 10MB
29
30 /// Hard cap on a single grep_files run. The directory walk plus per-file regex
31 /// is synchronous blocking work; without this it can run for minutes on a large
32 /// tree. Mirrors the file_search tool so both blocking searches behave the same.
33 const GREP_FILES_TIMEOUT: Duration = Duration::from_secs(30);
34
35 /// Result of a grep match
36 #[derive(Debug, Clone, Serialize, Deserialize)]
37 pub struct GrepMatch {
38 pub file: String,
39 pub line_number: usize,
40 pub line: String,
41 pub context_before: Vec<String>,
42 pub context_after: Vec<String>,
43 }
44
45 /// Tool for searching files using regex patterns
46 pub struct GrepFilesTool;
47
48 #[async_trait]
49 impl ToolSpec for GrepFilesTool {
50 fn name(&self) -> &'static str {
51 "grep_files"
52 }
53
54 fn model_visible(&self) -> bool {
55 true
56 }
57
58 fn description(&self) -> &'static str {
59 "Search for a regex pattern in workspace files. The pure-Rust search skips common non-code directories by default and returns matching lines with context."
60 }
61
62 fn input_schema(&self) -> Value {
63 json!({
64 "type": "object",
65 "properties": {
66 "pattern": {
67 "type": "string",
68 "description": "Regular expression pattern to search for"
69 },
70 "path": {
71 "type": "string",
72 "description": "Directory or file to search (relative to workspace, default: .)"
73 },
74 "include": {
75 "type": "array",
76 "items": {"type": "string"},
77 "description": "Glob patterns for files to include (e.g., ['*.rs', '*.ts'])"
78 },
79 "exclude": {
80 "type": "array",
81 "items": {"type": "string"},
82 "description": "Glob patterns for files to exclude (e.g., ['*.min.js', 'node_modules/*'])"
83 },
84 "context_lines": {
85 "type": "integer",
86 "description": "Number of context lines before and after each match (default: 2)"
87 },
88 "case_insensitive": {
89 "type": "boolean",
90 "description": "Whether to perform case-insensitive matching (default: false)"
91 },
92 "max_results": {
93 "type": "integer",
94 "description": "Maximum number of results to return (default: 100)"
95 }
96 },
97 "required": ["pattern"]
98 })
99 }
100
101 fn capabilities(&self) -> Vec<ToolCapability> {
102 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
103 }
104
105 fn supports_parallel(&self) -> bool {
106 true
107 }
108
109 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
110 let mut input = input;
111 apply_param_aliases(&mut input, PATH_ALIASES, "File search_content")?;
112 apply_param_aliases(&mut input, SEARCH_CONTENT_ALIASES, "File search_content")?;
113 SEARCH_CONTENT_PARAMS.reject_unknown(&input)?;
114
115 let pattern_str = required_str(&input, "pattern")?;
116 let path_str = optional_str(&input, "path")?.unwrap_or(".");
117 let context_lines = usize::try_from(optional_u64(&input, "context_lines", 2)?)
118 .unwrap_or(usize::MAX)
119 .min(1000);
120 let case_insensitive = optional_bool(&input, "case_insensitive", false)?;
121 let max_results = usize::try_from(optional_u64(&input, "max_results", MAX_RESULTS as u64)?)
122 .unwrap_or(MAX_RESULTS);
123
124 // Parse include patterns
125 let include_patterns: Vec<String> = input
126 .get("include")
127 .and_then(|v| v.as_array())
128 .map(|arr| {
129 arr.iter()
130 .filter_map(|v| v.as_str().map(String::from))
131 .collect()
132 })
133 .unwrap_or_default();
134
135 // Parse exclude patterns
136 let exclude_patterns: Vec<String> =
137 input.get("exclude").and_then(|v| v.as_array()).map_or_else(
138 || {
139 // Default exclusions for common non-code directories.
140 // Bare directory names skip the directory traversal entirely;
141 // `dir/*` filters files inside if the directory is already
142 // being walked (belt-and-suspenders — see #2200).
143 vec![
144 "node_modules".to_string(),
145 "node_modules/*".to_string(),
146 ".git".to_string(),
147 ".git/*".to_string(),
148 "target".to_string(),
149 "target/*".to_string(),
150 "*.min.js".to_string(),
151 "*.min.css".to_string(),
152 "dist".to_string(),
153 "dist/*".to_string(),
154 "build".to_string(),
155 "build/*".to_string(),
156 "__pycache__".to_string(),
157 "__pycache__/*".to_string(),
158 ".venv".to_string(),
159 ".venv/*".to_string(),
160 "venv".to_string(),
161 "venv/*".to_string(),
162 ]
163 },
164 |arr| {
165 arr.iter()
166 .filter_map(|v| v.as_str().map(String::from))
167 .collect()
168 },
169 );
170
171 // Build regex
172 let regex_pattern = if case_insensitive {
173 format!("(?i){pattern_str}")
174 } else {
175 pattern_str.to_string()
176 };
177
178 let regex = Regex::new(&regex_pattern)
179 .map_err(|e| ToolError::invalid_input(format!("Invalid regex pattern: {e}")))?;
180
181 // Resolve search path
182 let search_path = context.resolve_path(path_str)?;
183
184 let workspace = context.workspace.clone();
185 let cancel_token = context.cancel_token.clone();
186 let follow_symlinks = context.follow_symlinks;
187
188 // The directory walk and per-file regex are synchronous blocking work.
189 // Run them on a blocking worker bounded by a hard timeout so a huge tree
190 // can't pin the async runtime and leave the stop button unresponsive.
191 let result = run_blocking_grep(GREP_FILES_TIMEOUT, cancel_token.clone(), move || {
192 let cancel_token = cancel_token.as_ref();
193
194 // Stream the walk: each file is searched as it is discovered and
195 // the traversal stops as soon as the match budget is exhausted.
196 // Files are never materialized in a big Vec and file contents are
197 // read line-by-line, so memory stays bounded by the result set.
198 let mut results: Vec<GrepMatch> = Vec::new();
199 let mut files_searched = 0;
200 let mut total_matches = 0;
201
202 visit_files(
203 &search_path,
204 &include_patterns,
205 &exclude_patterns,
206 cancel_token,
207 follow_symlinks,
208 &mut |file_path| {
209 if results.len() >= max_results {
210 return Ok(WalkControl::Stop);
211 }
212 check_cancelled(cancel_token)?;
213
214 // Skip files that are too large
215 if let Ok(metadata) = fs::metadata(file_path)
216 && metadata.len() > MAX_FILE_SIZE
217 {
218 return Ok(WalkControl::Continue);
219 }
220
221 // Get relative path from workspace
222 let relative_path = file_path
223 .strip_prefix(&workspace)
224 .unwrap_or(file_path)
225 .to_string_lossy()
226 .to_string();
227
228 let budget = max_results - results.len();
229 let Some(file_matches) = search_file_streaming(
230 file_path,
231 &relative_path,
232 &regex,
233 context_lines,
234 budget,
235 cancel_token,
236 )?
237 else {
238 return Ok(WalkControl::Continue); // Skip binary or unreadable files
239 };
240
241 files_searched += 1;
242 total_matches += file_matches.len();
243 results.extend(file_matches);
244 Ok(WalkControl::Continue)
245 },
246 )?;
247
248 let matches_json: Vec<Value> = results
249 .iter()
250 .map(|item| grep_match_to_json(item, context_lines))
251 .collect();
252
253 // Build result. When context_lines == 1, return the single context
254 // line as a string instead of a one-item array. That keeps the common
255 // "show just the adjacent line" case easy for model callers to read.
256 Ok(json!({
257 "matches": matches_json,
258 "total_matches": total_matches,
259 "files_searched": files_searched,
260 "truncated": total_matches > max_results,
261 }))
262 })
263 .await?;
264
265 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
266 }
267 }
268
269 /// Run the synchronous grep walk on a blocking worker, cancellable via the
270 /// token and bounded by `timeout`. Mirrors `run_blocking_file_search`.
271 async fn run_blocking_grep<F>(
272 timeout: Duration,
273 cancel_token: Option<CancellationToken>,
274 search: F,
275 ) -> Result<Value, ToolError>
276 where
277 F: FnOnce() -> Result<Value, ToolError> + Send + 'static,
278 {
279 if cancel_token
280 .as_ref()
281 .is_some_and(CancellationToken::is_cancelled)
282 {
283 return Err(grep_cancelled());
284 }
285
286 let task = tokio::task::spawn_blocking(search);
287 let result = match cancel_token {
288 Some(token) => {
289 tokio::select! {
290 biased;
291 () = token.cancelled() => return Err(grep_cancelled()),
292 result = tokio::time::timeout(timeout, task) => result,
293 }
294 }
295 None => tokio::time::timeout(timeout, task).await,
296 };
297
298 let joined = result.map_err(|_| grep_timeout(timeout))?;
299 joined.map_err(|err| {
300 ToolError::execution_failed(format!("grep_files worker failed before completion: {err}"))
301 })?
302 }
303
304 fn grep_cancelled() -> ToolError {
305 ToolError::cancelled("grep_files cancelled before completion")
306 }
307
308 fn grep_timeout(timeout: Duration) -> ToolError {
309 ToolError::Timeout {
310 seconds: timeout.as_secs().max(1),
311 }
312 }
313
314 fn grep_match_to_json(item: &GrepMatch, context_lines: usize) -> Value {
315 if context_lines == 1 {
316 json!({
317 "file": item.file,
318 "line_number": item.line_number,
319 "line": item.line,
320 "context_before": item.context_before.first().cloned().unwrap_or_default(),
321 "context_after": item.context_after.first().cloned().unwrap_or_default(),
322 })
323 } else {
324 json!(item)
325 }
326 }
327
328 /// Search a single file line-by-line with a small ring buffer for
329 /// before-context, so file contents are never fully materialized.
330 ///
331 /// Returns `Ok(None)` when the file is unreadable or contains invalid UTF-8
332 /// anywhere — the same "skip binary or unreadable files" semantics as the
333 /// previous `read_to_string` implementation, which required the whole file to
334 /// be valid before contributing any match. At most `budget` matches are
335 /// recorded; the scan still runs to EOF so late invalid bytes disqualify the
336 /// file and pending after-context is completed.
337 fn search_file_streaming(
338 path: &Path,
339 relative_path: &str,
340 regex: &Regex,
341 context_lines: usize,
342 budget: usize,
343 cancel_token: Option<&CancellationToken>,
344 ) -> Result<Option<Vec<GrepMatch>>, ToolError> {
345 let Ok(file) = fs::File::open(path) else {
346 return Ok(None);
347 };
348 let mut reader = std::io::BufReader::new(file);
349 let mut raw: Vec<u8> = Vec::new();
350 let mut before: VecDeque<String> = VecDeque::new();
351 let mut matches: Vec<GrepMatch> = Vec::new();
352 // Matches still waiting for after-context lines: (index into `matches`,
353 // lines still needed). Entries complete in FIFO order.
354 let mut pending: VecDeque<(usize, usize)> = VecDeque::new();
355 let mut line_idx = 0usize;
356
357 loop {
358 raw.clear();
359 let n = match reader.read_until(b'\n', &mut raw) {
360 Ok(n) => n,
361 Err(_) => return Ok(None),
362 };
363 if n == 0 {
364 break;
365 }
366 check_cancelled(cancel_token)?;
367
368 // Mirror `str::lines`: strip the trailing '\n', and a '\r' only when
369 // it directly precedes that '\n'.
370 let mut end = raw.len();
371 if raw[..end].ends_with(b"\n") {
372 end -= 1;
373 if raw[..end].ends_with(b"\r") {
374 end -= 1;
375 }
376 }
377 let Ok(line) = std::str::from_utf8(&raw[..end]) else {
378 return Ok(None);
379 };
380
381 for (idx, remaining) in &mut pending {
382 matches[*idx].context_after.push(line.to_string());
383 *remaining -= 1;
384 }
385 while pending
386 .front()
387 .is_some_and(|(_, remaining)| *remaining == 0)
388 {
389 pending.pop_front();
390 }
391
392 if matches.len() < budget && regex.is_match(line) {
393 matches.push(GrepMatch {
394 file: relative_path.to_string(),
395 line_number: line_idx + 1,
396 line: line.to_string(),
397 context_before: before.iter().cloned().collect(),
398 context_after: Vec::new(),
399 });
400 if context_lines > 0 {
401 pending.push_back((matches.len() - 1, context_lines));
402 }
403 }
404
405 if context_lines > 0 {
406 if before.len() == context_lines {
407 before.pop_front();
408 }
409 before.push_back(line.to_string());
410 }
411 line_idx += 1;
412 }
413
414 Ok(Some(matches))
415 }
416
417 /// Flow control for the streaming file walk.
418 enum WalkControl {
419 Continue,
420 Stop,
421 }
422
423 /// Walk files matching the include/exclude patterns, invoking `visit` for
424 /// each one in traversal order. The walk stops early when `visit` returns
425 /// [`WalkControl::Stop`].
426 fn visit_files(
427 root: &Path,
428 include_patterns: &[String],
429 exclude_patterns: &[String],
430 cancel_token: Option<&CancellationToken>,
431 follow_symlinks: bool,
432 visit: &mut dyn FnMut(&Path) -> Result<WalkControl, ToolError>,
433 ) -> Result<(), ToolError> {
434 let mut visited_dirs: HashSet<PathBuf> = HashSet::new();
435 check_cancelled(cancel_token)?;
436
437 if root.is_file() {
438 visit(root)?;
439 return Ok(());
440 }
441
442 if follow_symlinks && let Ok(canonical_root) = root.canonicalize() {
443 visited_dirs.insert(canonical_root);
444 }
445
446 visit_files_recursive(
447 root,
448 root,
449 include_patterns,
450 exclude_patterns,
451 cancel_token,
452 &mut visited_dirs,
453 follow_symlinks,
454 visit,
455 )?;
456 Ok(())
457 }
458
459 #[allow(clippy::too_many_arguments)]
460 fn visit_files_recursive(
461 root: &Path,
462 current: &Path,
463 include_patterns: &[String],
464 exclude_patterns: &[String],
465 cancel_token: Option<&CancellationToken>,
466 visited_dirs: &mut HashSet<PathBuf>,
467 follow_symlinks: bool,
468 visit: &mut dyn FnMut(&Path) -> Result<WalkControl, ToolError>,
469 ) -> Result<WalkControl, ToolError> {
470 check_cancelled(cancel_token)?;
471
472 let entries = fs::read_dir(current).map_err(|e| {
473 ToolError::execution_failed(format!(
474 "Failed to read directory {}: {}",
475 current.display(),
476 e
477 ))
478 })?;
479
480 for entry in entries {
481 check_cancelled(cancel_token)?;
482
483 let entry = entry.map_err(|e| ToolError::execution_failed(e.to_string()))?;
484 let path = entry.path();
485 let file_type = entry.file_type().map_err(|e| {
486 ToolError::execution_failed(format!(
487 "Failed to inspect file type for {}: {}",
488 path.display(),
489 e
490 ))
491 })?;
492 if file_type.is_symlink() && !follow_symlinks {
493 continue;
494 }
495
496 // Get relative path for pattern matching
497 let relative = path.strip_prefix(root).unwrap_or(&path);
498 let relative_str = relative.to_string_lossy();
499
500 // Check exclusions
501 if should_exclude(&relative_str, exclude_patterns) {
502 continue;
503 }
504
505 // When following symlinks, resolve the target type for directories
506 // and files so symlinked dirs are traversed and symlinked files are
507 // included.
508 let effective_type = if file_type.is_symlink() && follow_symlinks {
509 match fs::metadata(&path) {
510 Ok(meta) => meta.file_type(),
511 Err(_) => continue,
512 }
513 } else {
514 file_type
515 };
516
517 if effective_type.is_dir() {
518 if follow_symlinks {
519 let canonical_dir = match path.canonicalize() {
520 Ok(canonical) => canonical,
521 Err(_) => continue,
522 };
523 if !visited_dirs.insert(canonical_dir) {
524 continue;
525 }
526 }
527 if let WalkControl::Stop = visit_files_recursive(
528 root,
529 &path,
530 include_patterns,
531 exclude_patterns,
532 cancel_token,
533 visited_dirs,
534 follow_symlinks,
535 visit,
536 )? {
537 return Ok(WalkControl::Stop);
538 }
539 } else if effective_type.is_file() {
540 // Sandbox read deny-list (S1). A recursive search is not a directed
541 // read request, so a denied file is skipped rather than failing the
542 // whole search — otherwise one `.env` anywhere in the tree would
543 // make `search` useless. The skip is logged; a directed
544 // `read_file`/`read`/`read_media` on the same path still returns an
545 // explicit refusal rather than an empty result.
546 if let Err(denial) = crate::sandbox::read_guard::active().check(&path) {
547 tracing::debug!(
548 target: "codewhale::sandbox::read_guard",
549 requested = %denial.requested.display(),
550 "sandbox read deny-list skipped a file during search"
551 );
552 continue;
553 }
554 // Check inclusions (if any specified)
555 if (include_patterns.is_empty() || should_include(&relative_str, include_patterns))
556 && let WalkControl::Stop = visit(&path)?
557 {
558 return Ok(WalkControl::Stop);
559 }
560 }
561 }
562
563 Ok(WalkControl::Continue)
564 }
565
566 fn check_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> {
567 if cancel_token.is_some_and(CancellationToken::is_cancelled) {
568 return Err(ToolError::cancelled("search cancelled before completion"));
569 }
570 Ok(())
571 }
572
573 /// Check if a path matches any of the exclude patterns
574 fn should_exclude(path: &str, patterns: &[String]) -> bool {
575 for pattern in patterns {
576 if matches_glob(path, pattern) {
577 return true;
578 }
579 }
580 false
581 }
582
583 /// Check if a path matches any of the include patterns
584 fn should_include(path: &str, patterns: &[String]) -> bool {
585 for pattern in patterns {
586 if matches_glob(path, pattern) {
587 return true;
588 }
589 }
590 false
591 }
592
593 /// Simple glob pattern matching
594 /// Supports: * (any chars), ** (any path), ? (single char)
595 pub(crate) fn matches_glob(path: &str, pattern: &str) -> bool {
596 // Handle ** for any path
597 if pattern.contains("**") {
598 let parts: Vec<&str> = pattern.split("**").collect();
599 if parts.len() == 2 {
600 let prefix = parts[0].trim_end_matches('/');
601 let suffix = parts[1].trim_start_matches('/');
602
603 if !prefix.is_empty() && !path.starts_with(prefix) {
604 return false;
605 }
606 if !suffix.is_empty() {
607 return path.ends_with(suffix)
608 || path
609 .split('/')
610 .any(|part| matches_simple_glob(part, suffix));
611 }
612 return path.starts_with(prefix) || prefix.is_empty();
613 }
614 }
615
616 // Handle patterns like "*.rs" - match against filename only
617 if pattern.starts_with('*') && !pattern.contains('/') {
618 let filename = path.rsplit('/').next().unwrap_or(path);
619 return matches_simple_glob(filename, pattern);
620 }
621
622 // Handle patterns with path components
623 if pattern.contains('/') {
624 return matches_simple_glob(path, pattern);
625 }
626
627 // Match against filename
628 let filename = path.rsplit('/').next().unwrap_or(path);
629 matches_simple_glob(filename, pattern)
630 }
631
632 /// Simple glob matching for single path component
633 fn matches_simple_glob(text: &str, pattern: &str) -> bool {
634 let mut text_chars = text.chars().peekable();
635 let mut pattern_chars = pattern.chars().peekable();
636
637 while let Some(p) = pattern_chars.next() {
638 match p {
639 '*' => {
640 // Match zero or more characters
641 let next_pattern: String = pattern_chars.collect();
642 if next_pattern.is_empty() {
643 return true;
644 }
645
646 // Try matching at each position (use char-indices to stay on
647 // UTF-8 boundaries — byte-index slicing panics on multi-byte
648 // characters like 冰糖, see #249).
649 let remaining: String = text_chars.collect();
650 for (i, _) in remaining.char_indices() {
651 if matches_simple_glob(&remaining[i..], &next_pattern) {
652 return true;
653 }
654 }
655 // Also try the empty suffix at end of string
656 if matches_simple_glob("", &next_pattern) {
657 return true;
658 }
659 return false;
660 }
661 '?' => {
662 // Match exactly one character
663 if text_chars.next().is_none() {
664 return false;
665 }
666 }
667 c => {
668 // Match literal character
669 if text_chars.next() != Some(c) {
670 return false;
671 }
672 }
673 }
674 }
675
676 text_chars.next().is_none()
677 }
678
679 // === Unit Tests ===
680
681 #[cfg(test)]
682 mod tests;
683
683 lines RUST