use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use glob::Pattern;
use ignore::WalkBuilder;
use regex::RegexBuilder;
use crate::tools::CrpMode;
use lean_ctx::core::protocol;
use lean_ctx::core::symbol_map::{self, SymbolMap};
use lean_ctx::core::tokens::count_tokens;
pub(crate) use lean_ctx::core::search_walk::{
MAX_FILE_SIZE, MAX_WALK_DEPTH, is_binary_ext, is_generated_file,
};
const MAX_EXPLICIT_FILE_SIZE: u64 = 64 * 1024 * 1024;
const MAX_NAMED_SKIPS: usize = 3;
const MAX_MATCH_LINE_WIDTH: usize = 150;
pub const NATIVE_GREP_BASELINE_FACTOR: f64 = 2.5;
pub struct SearchOutcome {
pub text: String,
pub modeled_baseline: usize,
pub observed_tokens: usize,
pub failed: bool,
pub secret_paths_withheld: usize,
}
impl SearchOutcome {
fn error(text: String) -> Self {
Self {
text,
modeled_baseline: 0,
observed_tokens: 0,
failed: true,
secret_paths_withheld: 0,
}
}
fn without_matches(text: String) -> Self {
Self {
text,
modeled_baseline: 0,
observed_tokens: 0,
failed: false,
secret_paths_withheld: 0,
}
}
fn from_observed(text: String, observed_tokens: usize) -> Self {
let modeled = (observed_tokens as f64 * NATIVE_GREP_BASELINE_FACTOR).ceil() as usize;
Self {
text,
modeled_baseline: modeled.max(observed_tokens),
observed_tokens,
failed: false,
secret_paths_withheld: 0,
}
}
fn withholding(mut self, secret_paths: usize) -> Self {
self.secret_paths_withheld = secret_paths;
self
}
}
fn search_deadline() -> Option<Duration> {
const DEFAULT_MS: u64 = 10_000;
let ms = std::env::var("LEAN_CTX_SEARCH_DEADLINE_MS")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok())
.unwrap_or(DEFAULT_MS);
(ms > 0).then(|| Duration::from_millis(ms))
}
#[allow(clippy::too_many_arguments)]
pub fn handle(
pattern: &str,
dir: &str,
include: Option<&str>,
max_results: usize,
crp_mode: CrpMode,
respect_gitignore: bool,
allow_secret_paths: bool,
anchored: bool,
) -> SearchOutcome {
handle_filtered(
pattern,
dir,
include,
max_results,
crp_mode,
respect_gitignore,
allow_secret_paths,
anchored,
None,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub fn handle_filtered(
pattern: &str,
dir: &str,
include: Option<&str>,
max_results: usize,
crp_mode: CrpMode,
respect_gitignore: bool,
allow_secret_paths: bool,
anchored: bool,
exclude: Option<&str>,
exclude_pattern: Option<&str>,
) -> SearchOutcome {
let literal = matches!(regex::Regex::new(pattern), Err(regex::Error::Syntax(_)))
.then(|| regex::escape(pattern));
let mut outcome = search_compiled_pattern(
literal.as_deref().unwrap_or(pattern),
dir,
include,
max_results,
crp_mode,
respect_gitignore,
allow_secret_paths,
anchored,
exclude,
exclude_pattern,
);
if literal.is_some() && !outcome.text.starts_with("ERROR:") {
outcome.text = format!(
"note: '{pattern}' is not valid regex — searched for it literally.\n{}",
outcome.text
);
}
outcome
}
#[allow(clippy::too_many_arguments)]
fn search_compiled_pattern(
pattern: &str,
dir: &str,
include: Option<&str>,
max_results: usize,
_crp_mode: CrpMode,
respect_gitignore: bool,
allow_secret_paths: bool,
anchored: bool,
exclude: Option<&str>,
exclude_pattern: Option<&str>,
) -> SearchOutcome {
let include_patterns = compile_include(include);
let exclude_patterns = compile_include(exclude);
const MAX_PATTERN_LEN: usize = 1024;
const MAX_REGEX_SIZE: usize = 1 << 20;
let redact = lean_ctx::core::redaction::redaction_enabled_for_active_role();
let protected = lean_ctx::core::policy::runtime::active().is_some();
let protected_root = lean_ctx::core::policy::diagnostics::project();
let admission = lean_ctx::core::context_admission::stores::StoreAdmission::current();
let mut protected_budget = lean_ctx::core::limits::max_read_bytes()
.min(lean_ctx::core::policy::content::MAX_PROTECTED_CONTENT_BYTES);
let mut matched_sources: Vec<std::path::PathBuf> = Vec::new();
if pattern.len() > MAX_PATTERN_LEN {
return SearchOutcome::error(format!(
"ERROR: pattern too long ({} > {MAX_PATTERN_LEN} chars)",
pattern.len()
));
}
let re = match RegexBuilder::new(pattern)
.size_limit(MAX_REGEX_SIZE)
.dfa_size_limit(MAX_REGEX_SIZE)
.build()
{
Ok(r) => r,
Err(e) => return SearchOutcome::error(format!("ERROR: invalid regex: {e}")),
};
let exclude_re = exclude_pattern.and_then(|p| {
RegexBuilder::new(p)
.size_limit(MAX_REGEX_SIZE)
.dfa_size_limit(MAX_REGEX_SIZE)
.build()
.ok()
});
let root = Path::new(dir);
if !root.exists() {
let hint = lean_ctx::core::path_suggest::hint(root);
return SearchOutcome::error(format!("ERROR: {dir} does not exist{hint}"));
}
if let Some(err) = crate::tools::walk_guard::deny_unsafe_walk_root(dir) {
return SearchOutcome::error(err);
}
let size_limit = if std::fs::metadata(root).is_ok_and(|m| m.is_file()) {
MAX_EXPLICIT_FILE_SIZE
} else {
MAX_FILE_SIZE
};
let mut files: Vec<PathBuf> = Vec::new();
let mut matches = Vec::new();
let mut raw_tokens_accum: usize = 0;
let mut files_searched = 0u32;
let mut files_skipped_size = 0u32;
let mut skipped_size_files: Vec<String> = Vec::new();
let mut files_skipped_encoding = 0u32;
let mut files_withheld = 0u32;
let mut skipped_boundary_files: Vec<(String, &'static str)> = Vec::new();
let mut files_skipped_special = 0u32;
let mut deadline_hit = false;
let mut cap_hit = false;
let mut budget_hit = false;
let mut narrowed_scope: Option<usize> = None;
let used_index = if !protected
&& let Some(idx) =
lean_ctx::core::search_index::get_fresh(dir, respect_gitignore, allow_secret_paths)
{
let candidates = idx.candidate_paths(pattern, &include_patterns, root);
if matches!(
candidates,
lean_ctx::core::search_index::CandidateSet::Narrowed(_)
) {
narrowed_scope = Some(idx.scope_count(&include_patterns, &exclude_patterns, root));
}
files = candidates.into_paths();
if !allow_secret_paths {
skipped_boundary_files = lean_ctx::core::search_index::boundary_skipped_files(
dir,
respect_gitignore,
&include_patterns,
);
}
true
} else {
false
};
if !used_index {
let walker = WalkBuilder::new(root)
.hidden(false)
.max_depth(Some(MAX_WALK_DEPTH))
.git_ignore(respect_gitignore)
.git_global(respect_gitignore)
.git_exclude(respect_gitignore)
.require_git(false)
.filter_entry(move |e| {
if respect_gitignore {
lean_ctx::core::walk_filter::keep_entry(e)
} else {
lean_ctx::core::cloud_files::keep_entry(e)
}
})
.build();
for entry in walker.filter_map(std::result::Result::ok) {
if entry.file_type().is_none_or(|ft| ft.is_dir()) {
continue;
}
if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
continue;
}
let path = entry.path();
if is_binary_ext(path) || is_generated_file(path) {
continue;
}
if !include_patterns.is_empty() {
let rel_str = filter_rel(path, root);
if !include_patterns.iter().any(|p| p.matches(&rel_str)) {
continue;
}
}
if !allow_secret_paths {
if let Some(reason) = lean_ctx::core::io_boundary::is_secret_like(path) {
skipped_boundary_files.push((filter_rel(path, root).into_owned(), reason));
continue;
}
}
files.push(path.to_path_buf());
}
skipped_boundary_files.sort_unstable();
}
if !exclude_patterns.is_empty() {
files.retain(|path| {
let rel_str = filter_rel(path, root);
!exclude_patterns.iter().any(|p| p.matches(&rel_str))
});
}
files.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
let files_in_scope = narrowed_scope.unwrap_or(files.len());
let index_pruned =
u32::try_from(files_in_scope.saturating_sub(files.len())).unwrap_or(u32::MAX);
let root_str = root.to_string_lossy();
let deadline = search_deadline().map(|budget| Instant::now() + budget);
for path in &files {
if matches.len() >= max_results {
cap_hit = true;
break;
}
if deadline.is_some_and(|dl| Instant::now() >= dl) {
deadline_hit = true;
break;
}
let file_len: u64;
let state = match std::fs::metadata(path) {
Ok(meta) if !meta.file_type().is_file() => {
files_skipped_special += 1;
continue;
}
Ok(meta) if meta.len() > size_limit => {
files_skipped_size += 1;
if skipped_size_files.len() < MAX_NAMED_SKIPS {
skipped_size_files.push(protocol::shorten_path_relative(
&path.to_string_lossy(),
&root_str,
));
}
continue;
}
Ok(meta) => {
file_len = meta.len();
lean_ctx::core::content_cache::FileState::from_metadata(&meta)
}
Err(_) => {
files_skipped_encoding += 1;
continue;
}
};
let content: std::sync::Arc<str> = if protected {
if (protected_budget as u64) < file_len {
budget_hit = true;
break;
}
let admitted = protected_root.as_ref().and_then(|root| {
lean_ctx::core::archive::authority::without_capture(|| {
crate::tools::ctx_read::read_file_for_tool_rooted_budgeted(
path.to_str()?,
root.to_str()?,
"ctx_search",
&mut protected_budget,
)
.ok()
})
});
if let Some(text) = admitted {
std::sync::Arc::from(text)
} else {
files_withheld += 1;
continue;
}
} else if let Some(cached) = state.and_then(|s| lean_ctx::core::content_cache::get(path, s))
{
lean_ctx::core::cache::record_search_content_read(true);
cached
} else {
if state.is_some() {
lean_ctx::core::cache::record_search_content_read(false);
}
let Ok(decoded) = lean_ctx::core::text_decode::read_text_checked(path) else {
files_skipped_encoding += 1;
continue;
};
let complete = decoded.complete();
if !complete
&& admission.admission_policy().gateway.mode
!= lean_ctx_protocol::context_gateway::GatewayModeV1::Developer
{
files_withheld += 1;
continue;
}
let text = decoded.text;
let Some(text) = admission.admit(&text, path) else {
files_withheld += 1;
continue;
};
let arc: std::sync::Arc<str> = std::sync::Arc::from(text);
if complete
&& let Some(s) = state
&& arc.len() as u64 <= MAX_FILE_SIZE
{
lean_ctx::core::content_cache::insert(
path,
s,
std::sync::Arc::clone(&arc),
&admission,
);
}
arc
};
files_searched += 1;
let mut file_enclosing: Option<EnclosingIndex> = None;
for (i, line) in content.lines().enumerate() {
if re.is_match(line) && !exclude_re.as_ref().is_some_and(|ex| ex.is_match(line)) {
let short_path =
protocol::shorten_path_relative(&path.to_string_lossy(), &root_str);
let source: &Path = path.as_ref();
if matched_sources
.last()
.is_none_or(|last| last.as_path() != source)
{
matched_sources.push(source.to_path_buf());
}
raw_tokens_accum += count_tokens(line.trim()) + 2;
let mut shown = if redact {
lean_ctx::core::redaction::redact_text(line.trim())
} else {
line.trim().to_string()
};
if shown.len() > MAX_MATCH_LINE_WIDTH {
shown.truncate(shown.floor_char_boundary(MAX_MATCH_LINE_WIDTH));
shown.push_str("...");
}
let tag = file_enclosing
.get_or_insert_with(|| EnclosingIndex::for_file(path, content.as_ref()))
.tag_for(i + 1)
.unwrap_or_default();
if anchored {
matches.push(format!(
"{short_path}:{}:{} {}{}",
i + 1,
lean_ctx::core::anchor::line_hash(line),
shown,
tag
));
} else {
matches.push(format!("{short_path}:{} {}{}", i + 1, shown, tag));
}
if matches.len() >= max_results {
cap_hit = true;
break;
}
}
}
}
let lineage_root = lean_ctx::core::policy::diagnostics::project().map_or_else(
|| dir.to_owned(),
|root| root.to_string_lossy().into_owned(),
);
for source in &matched_sources {
crate::tools::ctx_read::publish_requested_source(
&source.to_string_lossy(),
&lineage_root,
"ctx_search",
);
}
if matches.len() > 1 {
use std::collections::HashMap;
let mut file_counts: HashMap<String, usize> = HashMap::new();
for m in &matches {
let file = extract_file_from_match(m).to_string();
*file_counts.entry(file).or_default() += 1;
}
matches.sort_by(|a, b| {
let fa = extract_file_from_match(a);
let fb = extract_file_from_match(b);
let ca = file_counts.get(fa).copied().unwrap_or(0);
let cb = file_counts.get(fb).copied().unwrap_or(0);
cb.cmp(&ca).then_with(|| fa.cmp(fb))
});
}
let files_covered = files_searched.saturating_add(index_pruned);
if matches.is_empty() {
let mut msg = format!("0 matches for '{pattern}' (scanned {files_covered} files)");
if files_withheld > 0 {
msg.push_str(&format!(
" ({files_withheld} sources withheld under policy)"
));
}
if files_in_scope == 0 {
msg.push_str(&empty_scope_note(dir, include, exclude));
}
if files_skipped_size > 0 {
msg.push_str(&format!(
" ({files_skipped_size} large files skipped: {})",
name_skipped(&skipped_size_files, files_skipped_size)
));
}
if files_skipped_encoding > 0 {
msg.push_str(&format!(
" ({files_skipped_encoding} files skipped: binary/encoding)"
));
}
if let Some(note) = format_boundary_skips(&skipped_boundary_files, false) {
msg.push_str(¬e);
}
if files_skipped_special > 0 {
msg.push_str(&format!(
" ({files_skipped_special} special files skipped: not regular files)"
));
}
if deadline_hit {
msg.push_str(
" (search stopped at the time budget — refine the pattern or scope with path=)",
);
}
if budget_hit {
msg.push_str(&budget_note(files_covered));
}
return SearchOutcome::without_matches(msg).withholding(skipped_boundary_files.len());
}
let matched_files: Vec<&str> = {
let mut seen = HashSet::new();
matches
.iter()
.filter_map(|m| {
let file = extract_file_from_match(m);
if seen.insert(file) { Some(file) } else { None }
})
.collect()
};
let mut result = format!(
"{} matches in {} files (scanned {files_covered})",
matches.len(),
matched_files.len()
);
if matched_files.len() > 1 {
if matched_files.len() <= 10 {
result.push_str(" [");
result.push_str(&matched_files.join(", "));
result.push(']');
} else {
let shown: Vec<&str> = matched_files.iter().take(8).copied().collect();
result.push_str(&format!(
" [{}, +{} more]",
shown.join(", "),
matched_files.len() - 8
));
}
}
result.push_str(":\n");
if anchored {
result.push_str("[anchored: path:line:hh → edit via ctx_patch]\n");
}
result.push_str(&matches.join("\n"));
if files_withheld > 0 {
result.push_str(&format!(
"\n({files_withheld} sources withheld under policy)"
));
}
if files_skipped_size > 0 {
result.push_str(&format!(
"\n({files_skipped_size} files >512KB skipped: {})",
name_skipped(&skipped_size_files, files_skipped_size)
));
}
if files_skipped_encoding > 0 {
result.push_str(&format!(
"\n({files_skipped_encoding} files skipped: binary/encoding)"
));
}
if let Some(note) = format_boundary_skips(&skipped_boundary_files, true) {
result.push_str(¬e);
}
if files_skipped_special > 0 {
result.push_str(&format!(
"\n({files_skipped_special} special files skipped: not regular files)"
));
}
if deadline_hit {
result.push_str(&format!(
"\n(search stopped after the {}s budget — {files_covered} files scanned; \
refine the pattern or scope with path= for full coverage)",
search_deadline().map_or(0, |d| d.as_secs())
));
}
if budget_hit {
result.push('\n');
result.push_str(budget_note(files_covered).trim_start());
}
if cap_hit {
result.push_str(&format!(
"\n(stopped at max_results={max_results}: {files_covered} of {files_in_scope} \
files scanned; counts are a floor, not a total)"
));
}
if let Some(delta) = lean_ctx::core::search_delta::compute_delta(pattern, &matches) {
return SearchOutcome::from_observed(delta, raw_tokens_accum)
.withholding(skipped_boundary_files.len());
}
if symbol_map::substitution_enabled() {
let exts = extract_extensions(include);
let ext_refs: Vec<&str> = exts.iter().map(String::as_str).collect();
let mut sym = SymbolMap::new();
let idents = symbol_map::extract_identifiers(&result, &ext_refs);
for ident in &idents {
sym.register(ident);
}
if sym.len() >= 3 {
let sym_table = sym.format_table();
let compressed = sym.apply(&result);
let original_tok = count_tokens(&result);
let compressed_tok = count_tokens(&compressed) + count_tokens(&sym_table);
let net_saving = original_tok.saturating_sub(compressed_tok);
if original_tok > 0 && net_saving * 100 / original_tok >= 5 {
result = format!("{compressed}{sym_table}");
}
}
}
SearchOutcome::from_observed(result, raw_tokens_accum).withholding(skipped_boundary_files.len())
}
fn name_skipped(named: &[String], total: u32) -> String {
let mut out = named.join(", ");
let rest = (total as usize).saturating_sub(named.len());
if rest > 0 {
out.push_str(&format!(", +{rest} more"));
}
out
}
fn format_boundary_skips(skipped: &[(String, &'static str)], newline: bool) -> Option<String> {
if skipped.is_empty() {
return None;
}
const MAX_SHOW: usize = 10;
let n = skipped.len();
let details: Vec<String> = skipped
.iter()
.take(MAX_SHOW)
.map(|(p, r)| format!("{p} [{r}]"))
.collect();
let prefix = if newline { "\n(" } else { " (" };
let mut msg = format!(
"{prefix}{n} secret-like files skipped by boundary policy: {}",
details.join(", ")
);
if n > MAX_SHOW {
msg.push_str(&format!(", and {} more...", n - MAX_SHOW));
}
msg.push(')');
Some(msg)
}
struct EnclosingIndex {
spans: Vec<(usize, usize, String)>,
}
impl EnclosingIndex {
fn for_file(path: &Path, content: &str) -> Self {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
let mut spans: Vec<(usize, usize, String)> =
lean_ctx::core::signatures::extract_signatures(content, ext)
.into_iter()
.filter_map(|s| match (s.start_line, s.end_line) {
(Some(a), Some(b)) if b > a => Some((a, b, s.name)),
_ => None,
})
.collect();
spans.sort_by(|x, y| x.0.cmp(&y.0).then(x.1.cmp(&y.1)));
Self { spans }
}
fn tag_for(&self, line: usize) -> Option<String> {
let mut best: Option<&(usize, usize, String)> = None;
for sp in &self.spans {
if line >= sp.0 && line <= sp.1 {
match best {
None => best = Some(sp),
Some(b) if (sp.1 - sp.0) < (b.1 - b.0) => best = Some(sp),
_ => {}
}
}
}
best.map(|(start, _, name)| format!(" ∈{name}@L{start}"))
}
}
const MAX_INCLUDE_GLOBS: usize = 64;
fn compile_include(include: Option<&str>) -> Vec<Pattern> {
let Some(raw) = include else {
return Vec::new();
};
split_top_level_commas(raw)
.into_iter()
.flat_map(expand_braces)
.take(MAX_INCLUDE_GLOBS)
.filter(|g| !g.is_empty())
.map(|g| {
if g.contains('/') {
g
} else {
format!("**/{g}")
}
})
.filter_map(|g| Pattern::new(&g).ok())
.collect()
}
fn split_top_level_commas(raw: &str) -> Vec<&str> {
let mut pieces = Vec::new();
let mut depth = 0usize;
let mut start = 0;
for (i, c) in raw.char_indices() {
match c {
'{' => depth += 1,
'}' => depth = depth.saturating_sub(1),
',' if depth == 0 => {
pieces.push(raw[start..i].trim());
start = i + 1;
}
_ => {}
}
}
pieces.push(raw[start..].trim());
pieces
}
fn budget_note(files_covered: u32) -> String {
format!(
" (search stopped after {files_covered} files: the protected-content read budget of this \
search is spent; the remaining files were not scanned, not withheld — narrow with \
path= or include= for full coverage)"
)
}
fn filter_rel<'a>(path: &'a Path, root: &Path) -> std::borrow::Cow<'a, str> {
match path.strip_prefix(root) {
Ok(rel) if !rel.as_os_str().is_empty() => rel.to_string_lossy(),
_ => path
.file_name()
.map_or_else(|| path.to_string_lossy(), |name| name.to_string_lossy()),
}
}
fn empty_scope_note(dir: &str, include: Option<&str>, exclude: Option<&str>) -> String {
match (include, exclude) {
(Some(include), _) => format!(
" — include=`{include}` admits no file under {dir}, so nothing was searched. \
Check the glob; a list is `*.{{go,yaml}}` or `*.go,*.yaml`."
),
(None, Some(exclude)) => format!(
" — exclude=`{exclude}` removes every file under {dir}, so nothing was searched."
),
(None, None) => format!(" — {dir} holds no searchable text file."),
}
}
fn expand_braces(pattern: &str) -> Vec<String> {
let Some(open) = pattern.find('{') else {
return vec![pattern.to_string()];
};
let Some(close_rel) = pattern[open..].find('}') else {
return vec![pattern.to_string()];
};
let close = open + close_rel;
let prefix = &pattern[..open];
let inner = &pattern[open + 1..close];
let suffix = &pattern[close + 1..];
let mut out = Vec::new();
for alt in inner.split(',') {
let alt = alt.trim();
for expanded_suffix in expand_braces(suffix) {
out.push(format!("{prefix}{alt}{expanded_suffix}"));
if out.len() >= MAX_INCLUDE_GLOBS {
return out;
}
}
}
out
}
fn extract_extensions(include: Option<&str>) -> Vec<String> {
let Some(list) = include else {
return Vec::new();
};
split_top_level_commas(list)
.into_iter()
.flat_map(extensions_of_glob)
.collect()
}
fn extensions_of_glob(pattern: &str) -> Vec<String> {
let filename = pattern.rsplit('/').next().unwrap_or(pattern);
let Some(dot) = filename.rfind('.') else {
return Vec::new();
};
let ext_part = &filename[dot + 1..];
if let Some(inner) = ext_part.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
return inner
.split(',')
.map(|e| e.trim().to_string())
.filter(|e| !e.is_empty())
.collect();
}
if ext_part.is_empty() {
return Vec::new();
}
vec![ext_part.to_string()]
}
fn extract_file_from_match(line: &str) -> &str {
let start = if line.len() >= 2
&& line.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
&& line.as_bytes().get(1) == Some(&b':')
{
2
} else {
0
};
match line[start..].find(':') {
Some(pos) => &line[..start + pos],
None => line,
}
}
#[cfg(test)]
#[path = "tests.rs"]
mod tests;