lean_ctx/tools/ctx_search/
implementation.rs1use std::collections::HashSet;
2use std::path::Path;
3use std::path::PathBuf;
4use std::time::{Duration, Instant};
5
6use glob::Pattern;
7use ignore::WalkBuilder;
8use regex::RegexBuilder;
9
10use crate::core::protocol;
11use crate::core::symbol_map::{self, SymbolMap};
12use crate::core::tokens::count_tokens;
13use crate::tools::CrpMode;
14
15pub(crate) const MAX_FILE_SIZE: u64 = 512_000;
16pub(crate) const MAX_WALK_DEPTH: usize = 20;
17const MAX_MATCH_LINE_WIDTH: usize = 150;
18
19pub const NATIVE_GREP_BASELINE_FACTOR: f64 = 2.5;
26
27pub struct SearchOutcome {
29 pub text: String,
31 pub modeled_baseline: usize,
34 pub observed_tokens: usize,
37}
38
39impl SearchOutcome {
40 fn error(text: String) -> Self {
41 Self {
42 text,
43 modeled_baseline: 0,
44 observed_tokens: 0,
45 }
46 }
47
48 fn from_observed(text: String, observed_tokens: usize) -> Self {
49 let modeled = (observed_tokens as f64 * NATIVE_GREP_BASELINE_FACTOR).ceil() as usize;
50 Self {
51 text,
52 modeled_baseline: modeled.max(observed_tokens),
53 observed_tokens,
54 }
55 }
56}
57
58fn search_deadline() -> Option<Duration> {
65 const DEFAULT_MS: u64 = 10_000;
66 let ms = std::env::var("LEAN_CTX_SEARCH_DEADLINE_MS")
67 .ok()
68 .and_then(|v| v.trim().parse::<u64>().ok())
69 .unwrap_or(DEFAULT_MS);
70 (ms > 0).then(|| Duration::from_millis(ms))
71}
72
73#[allow(clippy::too_many_arguments)]
77pub fn handle(
78 pattern: &str,
79 dir: &str,
80 include: Option<&str>,
81 max_results: usize,
82 crp_mode: CrpMode,
83 respect_gitignore: bool,
84 allow_secret_paths: bool,
85 anchored: bool,
86) -> SearchOutcome {
87 handle_filtered(
88 pattern,
89 dir,
90 include,
91 max_results,
92 crp_mode,
93 respect_gitignore,
94 allow_secret_paths,
95 anchored,
96 None,
97 None,
98 )
99}
100
101#[allow(clippy::too_many_arguments)]
111pub fn handle_filtered(
112 pattern: &str,
113 dir: &str,
114 include: Option<&str>,
115 max_results: usize,
116 _crp_mode: CrpMode,
117 respect_gitignore: bool,
118 allow_secret_paths: bool,
119 anchored: bool,
120 exclude: Option<&str>,
121 exclude_pattern: Option<&str>,
122) -> SearchOutcome {
123 let include_patterns = compile_include(include);
130 let exclude_patterns = compile_include(exclude);
133 const MAX_PATTERN_LEN: usize = 1024;
134 const MAX_REGEX_SIZE: usize = 1 << 20; let redact = crate::core::redaction::redaction_enabled_for_active_role();
137 if pattern.len() > MAX_PATTERN_LEN {
138 return SearchOutcome::error(format!(
139 "ERROR: pattern too long ({} > {MAX_PATTERN_LEN} chars)",
140 pattern.len()
141 ));
142 }
143 let re = match RegexBuilder::new(pattern)
144 .size_limit(MAX_REGEX_SIZE)
145 .dfa_size_limit(MAX_REGEX_SIZE)
146 .build()
147 {
148 Ok(r) => r,
149 Err(e) => return SearchOutcome::error(format!("ERROR: invalid regex: {e}")),
150 };
151 let exclude_re = exclude_pattern.and_then(|p| {
155 RegexBuilder::new(p)
156 .size_limit(MAX_REGEX_SIZE)
157 .dfa_size_limit(MAX_REGEX_SIZE)
158 .build()
159 .ok()
160 });
161
162 let root = Path::new(dir);
163 if !root.exists() {
164 return SearchOutcome::error(format!("ERROR: {dir} does not exist"));
165 }
166 if let Some(err) = crate::tools::walk_guard::deny_unsafe_walk_root(dir) {
169 return SearchOutcome::error(err);
170 }
171
172 let mut files: Vec<PathBuf> = Vec::new();
173 let mut matches = Vec::new();
174 let mut raw_tokens_accum: usize = 0;
175 let mut files_searched = 0u32;
176 let mut files_skipped_size = 0u32;
177 let mut files_skipped_encoding = 0u32;
178 let mut files_skipped_boundary = 0u32;
179 let mut files_skipped_special = 0u32;
180 let mut deadline_hit = false;
181 let mut any_enclosing = false;
184
185 let used_index = if let Some(idx) =
192 crate::core::search_index::get_fresh(dir, respect_gitignore, allow_secret_paths)
193 {
194 files = idx
195 .candidate_paths(pattern, &include_patterns, root)
196 .into_paths();
197 true
198 } else {
199 false
200 };
201
202 if !used_index {
203 let walker = WalkBuilder::new(root)
208 .hidden(false)
209 .max_depth(Some(MAX_WALK_DEPTH))
210 .git_ignore(respect_gitignore)
211 .git_global(respect_gitignore)
212 .git_exclude(respect_gitignore)
213 .require_git(false)
214 .filter_entry(move |e| {
215 if respect_gitignore {
216 crate::core::walk_filter::keep_entry(e)
217 } else {
218 crate::core::cloud_files::keep_entry(e)
219 }
220 })
221 .build();
222
223 for entry in walker.filter_map(std::result::Result::ok) {
224 if entry.file_type().is_none_or(|ft| ft.is_dir()) {
225 continue;
226 }
227
228 if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
229 continue;
230 }
231
232 let path = entry.path();
233
234 if is_binary_ext(path) || is_generated_file(path) {
235 continue;
236 }
237
238 if !allow_secret_paths && crate::core::io_boundary::is_secret_like(path).is_some() {
239 files_skipped_boundary += 1;
240 continue;
241 }
242
243 if !include_patterns.is_empty() {
244 let rel = path.strip_prefix(root).unwrap_or(path);
245 let rel_str = rel.to_string_lossy();
246 if !include_patterns.iter().any(|p| p.matches(&rel_str)) {
247 continue;
248 }
249 }
250
251 files.push(path.to_path_buf());
255 }
256 }
257
258 if !exclude_patterns.is_empty() {
261 files.retain(|path| {
262 let rel = path.strip_prefix(root).unwrap_or(path);
263 let rel_str = rel.to_string_lossy();
264 !exclude_patterns.iter().any(|p| p.matches(&rel_str))
265 });
266 }
267
268 files.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
270
271 let root_str = root.to_string_lossy();
272 let deadline = search_deadline().map(|budget| Instant::now() + budget);
273 for path in &files {
274 if matches.len() >= max_results {
275 break;
276 }
277
278 if deadline.is_some_and(|dl| Instant::now() >= dl) {
282 deadline_hit = true;
283 break;
284 }
285
286 let state = match std::fs::metadata(path) {
291 Ok(meta) if !meta.file_type().is_file() => {
292 files_skipped_special += 1;
293 continue;
294 }
295 Ok(meta) if meta.len() > MAX_FILE_SIZE => {
296 files_skipped_size += 1;
297 continue;
298 }
299 Ok(meta) => crate::core::content_cache::FileState::from_metadata(&meta),
300 Err(_) => {
301 files_skipped_encoding += 1;
302 continue;
303 }
304 };
305
306 let content: std::sync::Arc<str> =
312 if let Some(cached) = state.and_then(|s| crate::core::content_cache::get(path, s)) {
313 cached
314 } else {
315 let Ok(text) = std::fs::read_to_string(path) else {
316 files_skipped_encoding += 1;
317 continue;
318 };
319 let arc: std::sync::Arc<str> = std::sync::Arc::from(text);
320 if let Some(s) = state {
321 crate::core::content_cache::insert(path, s, std::sync::Arc::clone(&arc));
322 }
323 arc
324 };
325
326 files_searched += 1;
327 let mut file_enclosing: Option<EnclosingIndex> = None;
330
331 for (i, line) in content.lines().enumerate() {
332 if re.is_match(line) && !exclude_re.as_ref().is_some_and(|ex| ex.is_match(line)) {
334 let short_path =
335 protocol::shorten_path_relative(&path.to_string_lossy(), &root_str);
336 raw_tokens_accum += count_tokens(line.trim()) + 2;
338 let mut shown = if redact {
339 crate::core::redaction::redact_text(line.trim())
340 } else {
341 line.trim().to_string()
342 };
343 if shown.len() > MAX_MATCH_LINE_WIDTH {
344 shown.truncate(shown.floor_char_boundary(MAX_MATCH_LINE_WIDTH));
345 shown.push_str("...");
346 }
347 let tag = file_enclosing
352 .get_or_insert_with(|| EnclosingIndex::for_file(path, content.as_ref()))
353 .tag_for(i + 1);
354 if tag.is_some() {
355 any_enclosing = true;
356 }
357 let tag = tag.unwrap_or_default();
358 if anchored {
362 matches.push(format!(
363 "{short_path}:{}:{} {}{}",
364 i + 1,
365 crate::core::anchor::line_hash(line),
366 shown,
367 tag
368 ));
369 } else {
370 matches.push(format!("{short_path}:{} {}{}", i + 1, shown, tag));
371 }
372 if matches.len() >= max_results {
373 break;
374 }
375 }
376 }
377 }
378
379 if matches.len() > 1 {
382 use std::collections::HashMap;
383 let mut file_counts: HashMap<String, usize> = HashMap::new();
384 for m in &matches {
385 let file = extract_file_from_match(m).to_string();
386 *file_counts.entry(file).or_default() += 1;
387 }
388 matches.sort_by(|a, b| {
389 let fa = extract_file_from_match(a);
390 let fb = extract_file_from_match(b);
391 let ca = file_counts.get(fa).copied().unwrap_or(0);
392 let cb = file_counts.get(fb).copied().unwrap_or(0);
393 cb.cmp(&ca).then_with(|| fa.cmp(fb))
394 });
395 }
396
397 if matches.is_empty() {
398 let mut msg = format!("0 matches for '{pattern}' in {files_searched} files");
399 if files_skipped_size > 0 {
400 msg.push_str(&format!(" ({files_skipped_size} large files skipped)"));
401 }
402 if files_skipped_encoding > 0 {
403 msg.push_str(&format!(
404 " ({files_skipped_encoding} files skipped: binary/encoding)"
405 ));
406 }
407 if files_skipped_boundary > 0 {
408 msg.push_str(&format!(
409 " ({files_skipped_boundary} secret-like files skipped by boundary policy)"
410 ));
411 }
412 if files_skipped_special > 0 {
413 msg.push_str(&format!(
414 " ({files_skipped_special} special files skipped: not regular files)"
415 ));
416 }
417 if deadline_hit {
418 msg.push_str(
419 " (search stopped at the time budget — refine the pattern or scope with path=)",
420 );
421 }
422 return SearchOutcome::error(msg);
423 }
424
425 let matched_files: Vec<&str> = {
427 let mut seen = HashSet::new();
428 matches
429 .iter()
430 .filter_map(|m| {
431 let file = extract_file_from_match(m);
432 if seen.insert(file) { Some(file) } else { None }
433 })
434 .collect()
435 };
436
437 let mut result = format!("{} matches in {} files", matches.len(), files_searched);
438 if matched_files.len() > 1 {
439 if matched_files.len() <= 10 {
440 result.push_str(" [");
441 result.push_str(&matched_files.join(", "));
442 result.push(']');
443 } else {
444 let shown: Vec<&str> = matched_files.iter().take(8).copied().collect();
445 result.push_str(&format!(
446 " [{}, +{} more]",
447 shown.join(", "),
448 matched_files.len() - 8
449 ));
450 }
451 }
452 result.push_str(":\n");
453 if anchored {
455 result.push_str("[anchored: path:line:hh → edit via ctx_patch]\n");
456 }
457 if any_enclosing {
460 result.push_str(
461 "[∈ enclosing symbol → ctx_search(action=symbol, handle=\"path#name@Lstart\")]\n",
462 );
463 }
464 result.push_str(&matches.join("\n"));
465
466 if files_skipped_size > 0 {
467 result.push_str(&format!("\n({files_skipped_size} files >512KB skipped)"));
468 }
469 if files_skipped_encoding > 0 {
470 result.push_str(&format!(
471 "\n({files_skipped_encoding} files skipped: binary/encoding)"
472 ));
473 }
474 if files_skipped_boundary > 0 {
475 result.push_str(&format!(
476 "\n({files_skipped_boundary} secret-like files skipped by boundary policy)"
477 ));
478 }
479 if files_skipped_special > 0 {
480 result.push_str(&format!(
481 "\n({files_skipped_special} special files skipped: not regular files)"
482 ));
483 }
484 if deadline_hit {
485 result.push_str(&format!(
486 "\n(search stopped after the {}s budget — {files_searched} files scanned; \
487 refine the pattern or scope with path= for full coverage)",
488 search_deadline().map_or(0, |d| d.as_secs())
489 ));
490 }
491
492 let scope_hint = monorepo_scope_hint(&matches, dir);
496
497 if let Some(delta) = crate::core::search_delta::compute_delta(pattern, &matches) {
498 return SearchOutcome::from_observed(delta, raw_tokens_accum);
499 }
500
501 if symbol_map::substitution_enabled() {
502 let exts = extract_extensions(include);
503 let ext_refs: Vec<&str> = exts.iter().map(String::as_str).collect();
504 let mut sym = SymbolMap::new();
505 let idents = symbol_map::extract_identifiers(&result, &ext_refs);
506 for ident in &idents {
507 sym.register(ident);
508 }
509 if sym.len() >= 3 {
510 let sym_table = sym.format_table();
511 let compressed = sym.apply(&result);
512 let original_tok = count_tokens(&result);
513 let compressed_tok = count_tokens(&compressed) + count_tokens(&sym_table);
514 let net_saving = original_tok.saturating_sub(compressed_tok);
515 if original_tok > 0 && net_saving * 100 / original_tok >= 5 {
516 result = format!("{compressed}{sym_table}");
517 }
518 }
519 }
520
521 if let Some(hint) = scope_hint {
522 result.push_str(&hint);
523 }
524
525 SearchOutcome::from_observed(result, raw_tokens_accum)
526}
527
528pub(crate) fn is_binary_ext(path: &Path) -> bool {
529 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
530 matches!(
531 ext,
532 "png"
533 | "jpg"
534 | "jpeg"
535 | "gif"
536 | "webp"
537 | "ico"
538 | "svg"
539 | "woff"
540 | "woff2"
541 | "ttf"
542 | "eot"
543 | "pdf"
544 | "zip"
545 | "tar"
546 | "gz"
547 | "br"
548 | "zst"
549 | "bz2"
550 | "xz"
551 | "mp3"
552 | "mp4"
553 | "webm"
554 | "ogg"
555 | "wasm"
556 | "so"
557 | "dylib"
558 | "dll"
559 | "exe"
560 | "lock"
561 | "map"
562 | "snap"
563 | "patch"
564 | "db"
565 | "sqlite"
566 | "parquet"
567 | "arrow"
568 | "bin"
569 | "o"
570 | "a"
571 | "class"
572 | "pyc"
573 | "pyo"
574 )
575}
576
577pub(crate) fn is_generated_file(path: &Path) -> bool {
578 let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
579 name.ends_with(".min.js")
580 || name.ends_with(".min.css")
581 || name.ends_with(".bundle.js")
582 || name.ends_with(".chunk.js")
583 || name.ends_with(".d.ts")
584 || name.ends_with(".js.map")
585 || name.ends_with(".css.map")
586}
587
588struct EnclosingIndex {
596 spans: Vec<(usize, usize, String)>,
598}
599
600impl EnclosingIndex {
601 fn for_file(path: &Path, content: &str) -> Self {
602 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
603 let mut spans: Vec<(usize, usize, String)> =
604 crate::core::signatures::extract_signatures(content, ext)
605 .into_iter()
606 .filter_map(|s| match (s.start_line, s.end_line) {
607 (Some(a), Some(b)) if b > a => Some((a, b, s.name)),
608 _ => None,
609 })
610 .collect();
611 spans.sort_by(|x, y| x.0.cmp(&y.0).then(x.1.cmp(&y.1)));
612 Self { spans }
613 }
614
615 fn tag_for(&self, line: usize) -> Option<String> {
618 let mut best: Option<&(usize, usize, String)> = None;
619 for sp in &self.spans {
620 if line >= sp.0 && line <= sp.1 {
621 match best {
622 None => best = Some(sp),
623 Some(b) if (sp.1 - sp.0) < (b.1 - b.0) => best = Some(sp),
624 _ => {}
625 }
626 }
627 }
628 best.map(|(start, _, name)| format!(" ∈{name}@L{start}"))
629 }
630}
631
632const MAX_INCLUDE_GLOBS: usize = 64;
635
636fn compile_include(include: Option<&str>) -> Vec<Pattern> {
648 let Some(raw) = include else {
649 return Vec::new();
650 };
651 expand_braces(raw)
652 .into_iter()
653 .take(MAX_INCLUDE_GLOBS)
654 .filter(|g| !g.is_empty())
655 .map(|g| {
656 if g.contains('/') {
657 g
658 } else {
659 format!("**/{g}")
660 }
661 })
662 .filter_map(|g| Pattern::new(&g).ok())
663 .collect()
664}
665
666fn expand_braces(pattern: &str) -> Vec<String> {
670 let Some(open) = pattern.find('{') else {
671 return vec![pattern.to_string()];
672 };
673 let Some(close_rel) = pattern[open..].find('}') else {
674 return vec![pattern.to_string()];
675 };
676 let close = open + close_rel;
677 let prefix = &pattern[..open];
678 let inner = &pattern[open + 1..close];
679 let suffix = &pattern[close + 1..];
680
681 let mut out = Vec::new();
682 for alt in inner.split(',') {
683 let alt = alt.trim();
684 for expanded_suffix in expand_braces(suffix) {
685 out.push(format!("{prefix}{alt}{expanded_suffix}"));
686 if out.len() >= MAX_INCLUDE_GLOBS {
687 return out;
688 }
689 }
690 }
691 out
692}
693
694fn extract_extensions(include: Option<&str>) -> Vec<String> {
704 let Some(pattern) = include else {
705 return Vec::new();
706 };
707 let filename = pattern.rsplit('/').next().unwrap_or(pattern);
708 let Some(dot) = filename.rfind('.') else {
709 return Vec::new();
710 };
711 let ext_part = &filename[dot + 1..];
712
713 if let Some(inner) = ext_part.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
714 return inner
715 .split(',')
716 .map(|e| e.trim().to_string())
717 .filter(|e| !e.is_empty())
718 .collect();
719 }
720
721 if ext_part.is_empty() {
722 return Vec::new();
723 }
724 vec![ext_part.to_string()]
725}
726
727fn extract_file_from_match(line: &str) -> &str {
729 let start = if line.len() >= 2
730 && line.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
731 && line.as_bytes().get(1) == Some(&b':')
732 {
733 2
734 } else {
735 0
736 };
737 match line[start..].find(':') {
738 Some(pos) => &line[..start + pos],
739 None => line,
740 }
741}
742
743fn monorepo_scope_hint(matches: &[String], search_dir: &str) -> Option<String> {
744 let top_dirs: HashSet<&str> = matches
745 .iter()
746 .filter_map(|m| {
747 let path = extract_file_from_match(m);
748 let relative = path.strip_prefix("./").unwrap_or(path);
749 let relative = relative.strip_prefix(search_dir).unwrap_or(relative);
750 let relative = relative.strip_prefix('/').unwrap_or(relative);
751 relative.split('/').next()
752 })
753 .collect();
754
755 if top_dirs.len() > 3 {
756 let mut dirs: Vec<&&str> = top_dirs.iter().collect();
757 dirs.sort();
758 let dir_list: Vec<String> = dirs.iter().take(6).map(|d| format!("'{d}'")).collect();
759 let extra = if top_dirs.len() > 6 {
760 format!(", +{} more", top_dirs.len() - 6)
761 } else {
762 String::new()
763 };
764 Some(format!(
765 "\n\nResults span {} directories ({}{}). \
766 Use the 'path' parameter to scope to a specific service, \
767 e.g. path=\"{}/\".",
768 top_dirs.len(),
769 dir_list.join(", "),
770 extra,
771 dirs[0]
772 ))
773 } else {
774 None
775 }
776}
777
778#[cfg(test)]
779#[path = "tests.rs"]
780mod tests;