use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
use super::memo::{
compute_content_digest, ExactMemoStore, MemoError, MemoKey, ServeOutcome, VerifiedExactSet,
};
use crate::commands::semantic_search::comparator::{
score_free_r3_cmp, CandidateResult, SymbolOffsetRange,
};
use crate::commands::semantic_search::evidence_descriptor::EvidenceDescriptor;
use crate::commands::semantic_search::generation_token::GenerationToken;
use crate::commands::semantic_search::plan_table::SearchLaneKind;
use crate::commands::semantic_search::{LaneExecution, LaneInput, SearchLane};
use crate::inspect::job::is_test_file;
use crate::query_shape::{contains_all_content_tokens, extract_content_tokens};
use crate::search_index::{
read_search_corpus_file, SearchCorpusEligibility, SearchIndex, SearchIndexSnapshot,
DEFAULT_MAX_FILE_SIZE,
};
pub const DEFAULT_FALLBACK_FILE_LIMIT: usize = 1_000;
pub const DEFAULT_FALLBACK_RESULT_LIMIT: usize = 100;
pub fn assemble_bounded_reply(bounded_line: Option<&str>, trailer: &str) -> String {
if let Some(line) = bounded_line {
format!("{line}\n{trailer}")
} else {
trailer.to_string()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrailerReason {
Exhausted,
DepthCap,
MoreAtGreaterDepth,
}
pub fn format_trailer(shown: usize, total_or_bound: usize, reason: TrailerReason) -> String {
match reason {
TrailerReason::Exhausted => format!("shown {shown} of {total_or_bound} (exhausted)"),
TrailerReason::DepthCap => format!("shown {shown} of {total_or_bound}+ (depth cap)"),
TrailerReason::MoreAtGreaterDepth => {
format!("shown {shown} of {total_or_bound}+ (more at greater depth)")
}
}
}
pub fn exact_phrase(query: &str) -> &str {
let trimmed = query.trim();
if trimmed.len() < 2 {
return trimmed;
}
let first = trimmed.as_bytes()[0];
let last = trimmed.as_bytes()[trimmed.len() - 1];
if matches!(first, b'\'' | b'"') && first == last {
&trimmed[1..trimmed.len() - 1]
} else {
trimmed
}
}
pub fn normalize_exact_phrase(text: &str) -> String {
text.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.to_ascii_lowercase()
}
pub fn e2_window_scan_needed(normalized_text: &str, content_tokens: &[String]) -> bool {
content_tokens.len() >= 2
&& content_tokens
.iter()
.all(|token| normalized_text.contains(token))
}
#[derive(Clone, Default)]
pub struct FallbackExactOptions {
pub file_limit: Option<usize>,
pub result_limit: Option<usize>,
pub time_limit: Option<Duration>,
pub delay_hook: Option<Arc<dyn Fn(&Path) + Send + Sync>>,
pub force_directory_order: bool,
}
#[derive(Clone, Debug)]
pub struct FallbackExactResult {
pub verified_set: VerifiedExactSet,
pub files_visited: usize,
pub bound_reason: Option<String>,
}
pub struct ExactLane {
pub memo: Arc<ExactMemoStore>,
pub fallback_file_limit: usize,
pub fallback_result_limit: usize,
}
impl Default for ExactLane {
fn default() -> Self {
Self::new()
}
}
impl ExactLane {
pub fn new() -> Self {
Self {
memo: Arc::new(ExactMemoStore::new()),
fallback_file_limit: DEFAULT_FALLBACK_FILE_LIMIT,
fallback_result_limit: DEFAULT_FALLBACK_RESULT_LIMIT,
}
}
pub fn with_memo(memo: Arc<ExactMemoStore>) -> Self {
Self {
memo,
fallback_file_limit: DEFAULT_FALLBACK_FILE_LIMIT,
fallback_result_limit: DEFAULT_FALLBACK_RESULT_LIMIT,
}
}
pub fn execute_ready_mode(
&self,
snapshot: &SearchIndexSnapshot,
project_root: &Path,
query: &str,
include_tests: bool,
) -> VerifiedExactSet {
let matches = snapshot.whole_corpus_exact_pass(
query,
project_root,
Some(&|path| include_tests || !path.to_str().map_or(false, is_test_file)),
);
let mut results = Vec::new();
let mut file_digests = HashMap::new();
for m in matches {
file_digests
.entry(m.path.clone())
.or_insert(m.content_digest);
results.push(CandidateResult::new_exact(
m.path,
m.symbol_range,
m.evidence,
));
}
results.sort_by(score_free_r3_cmp);
VerifiedExactSet {
results,
file_digests,
bound_disclosure: None,
stability_void: false,
}
}
pub fn execute_fallback_mode(
&self,
project_root: &Path,
query: &str,
include_tests: bool,
options: &FallbackExactOptions,
) -> FallbackExactResult {
let file_limit = options.file_limit.unwrap_or(self.fallback_file_limit);
let result_limit = options.result_limit.unwrap_or(self.fallback_result_limit);
let deadline = options.time_limit.map(|d| Instant::now() + d);
let mut files = Vec::new();
collect_files_recursive(project_root, &mut files);
if !include_tests {
files.retain(|p| !p.to_str().map_or(false, is_test_file));
}
if options.force_directory_order {
} else {
files.sort_by(|a, b| {
let rel_a = a.strip_prefix(project_root).unwrap_or(a);
let rel_b = b.strip_prefix(project_root).unwrap_or(b);
rel_a
.as_os_str()
.as_encoded_bytes()
.cmp(rel_b.as_os_str().as_encoded_bytes())
});
}
let phrase = exact_phrase(query);
let norm_phrase = normalize_exact_phrase(phrase);
let content_tokens = extract_content_tokens(query);
let mut results = Vec::new();
let mut file_digests = HashMap::new();
let mut files_visited = 0;
let mut bound_reason: Option<String> = None;
let mut stability_void = false;
for file_path in files {
if let Some(dl) = deadline {
if Instant::now() >= dl {
bound_reason = Some("time limit".to_string());
stability_void = true;
break;
}
}
if files_visited >= file_limit {
bound_reason = Some("file limit".to_string());
break;
}
if results.len() >= result_limit {
bound_reason = Some("result limit".to_string());
break;
}
let SearchCorpusEligibility::Eligible(file) =
read_search_corpus_file(&file_path, DEFAULT_MAX_FILE_SIZE)
else {
continue;
};
files_visited += 1;
if let Some(delay_fn) = &options.delay_hook {
delay_fn(&file_path);
}
let digest = compute_content_digest(&file.bytes);
file_digests.insert(file_path.clone(), digest);
let text = String::from_utf8_lossy(&file.bytes);
if let Some(mut candidates) =
verify_exact_matches_in_text(&file_path, &text, &norm_phrase, &content_tokens)
{
for candidate in &mut candidates {
candidate.evidence.generated = file.generated;
}
results.extend(candidates);
}
}
let bound_disclosure = if let Some(ref reason) = bound_reason {
if reason == "time limit" {
Some(format!(
"exact pass: bounded ({files_visited} files, time limit) - page stability void"
))
} else {
Some(format!(
"exact pass: bounded ({files_visited} files, {reason})"
))
}
} else {
Some(format!(
"exact pass: bounded ({files_visited} files, index not ready)"
))
};
results.sort_by(score_free_r3_cmp);
FallbackExactResult {
verified_set: VerifiedExactSet {
results,
file_digests,
bound_disclosure,
stability_void,
},
files_visited,
bound_reason,
}
}
pub fn search(
&self,
index: Option<&SearchIndex>,
project_root: &Path,
snapshot_generation: GenerationToken,
query: &str,
include_tests: bool,
offset: usize,
top_k: usize,
fallback_options: Option<&FallbackExactOptions>,
) -> Result<ServeOutcome, MemoError> {
let key = MemoKey::new(project_root, snapshot_generation, query, include_tests);
let default_opts = FallbackExactOptions::default();
let fallback_opts = fallback_options.unwrap_or(&default_opts);
self.memo.get_or_verify(&key, offset, top_k, || {
let is_ready = index.as_ref().is_some_and(|idx| idx.is_ready());
if is_ready {
let snapshot = index.unwrap().snapshot();
Ok(self.execute_ready_mode(&snapshot, project_root, query, include_tests))
} else {
let fallback =
self.execute_fallback_mode(project_root, query, include_tests, fallback_opts);
Ok(fallback.verified_set)
}
})
}
}
impl SearchLane for ExactLane {
fn kind(&self) -> SearchLaneKind {
SearchLaneKind::Exact
}
fn execute(&self, input: &LaneInput<'_>) -> LaneExecution {
let snapshot = input.index.snapshot();
let mut candidates = self
.execute_ready_mode(&snapshot, input.root, input.query, input.include_tests)
.results;
if input.shape != crate::commands::semantic_search::SearchShape::Identifier {
candidates.retain(|candidate| {
candidate.evidence.kind
!= crate::commands::semantic_search::EvidenceKind::Definition
});
}
LaneExecution {
kind: self.kind(),
candidates,
}
}
}
fn collect_files_recursive(dir: &Path, files: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.')
|| name == "target"
|| name == "node_modules"
|| name == ".git"
{
continue;
}
}
collect_files_recursive(&path, files);
} else if path.is_file() {
files.push(path);
}
}
}
pub fn verify_exact_matches_in_text(
file_path: &Path,
text: &str,
norm_phrase: &str,
content_tokens: &[String],
) -> Option<Vec<CandidateResult>> {
let mut matches = Vec::new();
let norm_text = normalize_exact_phrase(text);
let symbols = scan_symbols_in_text(text);
for (name, range) in &symbols {
let Some(sym_text) = text.get(range.start..range.end.min(text.len())) else {
debug_assert!(false, "symbol range {range:?} is not char-boundary aligned");
continue;
};
let norm_sym_text = normalize_exact_phrase(sym_text);
if !content_tokens.is_empty()
&& content_tokens
.iter()
.any(|token| name.to_ascii_lowercase() == *token)
{
matches.push(CandidateResult::new_exact(
file_path.to_path_buf(),
Some(*range),
EvidenceDescriptor::for_definition(true, false),
));
} else if !norm_phrase.is_empty() && norm_sym_text.contains(norm_phrase) {
let occ = norm_sym_text.matches(norm_phrase).count();
matches.push(CandidateResult::new_exact(
file_path.to_path_buf(),
Some(*range),
EvidenceDescriptor::for_e1(occ, true, false),
));
}
}
if matches.is_empty() && !norm_phrase.is_empty() {
let occ = norm_text.matches(norm_phrase).count();
if occ > 0 {
matches.push(CandidateResult::new_exact(
file_path.to_path_buf(),
None,
EvidenceDescriptor::for_e1(occ, true, false),
));
}
}
if matches.is_empty() && e2_window_scan_needed(&norm_text, content_tokens) {
let lines: Vec<&str> = text.lines().collect();
for width in 1..=3 {
if lines.len() < width {
continue;
}
if lines
.windows(width)
.any(|w| contains_all_content_tokens(&w.join("\n"), content_tokens))
{
matches.push(CandidateResult::new_exact(
file_path.to_path_buf(),
None,
EvidenceDescriptor::for_e2(width, true, false),
));
break;
}
}
}
if matches.is_empty() {
None
} else {
Some(matches)
}
}
pub fn scan_symbols_in_text(text: &str) -> Vec<(String, SymbolOffsetRange)> {
let mut symbols = Vec::new();
let mut current_offset = 0;
for segment in text.split_inclusive('\n') {
let line = segment
.strip_suffix('\n')
.map(|line| line.strip_suffix('\r').unwrap_or(line))
.unwrap_or(segment);
let trimmed = line.trim_start();
let leading_spaces = line.len() - trimmed.len();
let line_offset = current_offset + leading_spaces;
let (name_opt, declaration_line_only) = if let Some(rest) = trimmed.strip_prefix("pub fn ")
{
(extract_identifier(rest), false)
} else if let Some(rest) = trimmed.strip_prefix("fn ") {
(extract_identifier(rest), false)
} else if let Some(rest) = trimmed.strip_prefix("pub(crate) fn ") {
(extract_identifier(rest), false)
} else if let Some(rest) = trimmed.strip_prefix("def ") {
(extract_identifier(rest), false)
} else if let Some(rest) = trimmed.strip_prefix("export function ") {
(extract_identifier(rest), false)
} else if let Some(rest) = trimmed.strip_prefix("function ") {
(extract_identifier(rest), false)
} else if let Some(rest) = trimmed.strip_prefix("pub struct ") {
(extract_identifier(rest), false)
} else if let Some(rest) = trimmed.strip_prefix("struct ") {
(extract_identifier(rest), false)
} else {
(
[
"export namespace ",
"namespace ",
"export class ",
"class ",
"export interface ",
"interface ",
"export enum ",
"pub enum ",
"enum ",
"pub trait ",
"trait ",
"export type ",
"pub type ",
"type ",
"export const ",
"pub const ",
"const ",
]
.into_iter()
.find_map(|prefix| trimmed.strip_prefix(prefix).and_then(extract_identifier))
.or_else(|| extract_field_identifier(trimmed)),
true,
)
};
if let Some(name) = name_opt {
let start = line_offset;
debug_assert!(text.is_char_boundary(start));
let mut end = if declaration_line_only {
(current_offset + line.len()).min(text.len())
} else {
(start + 500).min(text.len())
};
while end > start && !text.is_char_boundary(end) {
end -= 1;
}
symbols.push((name, SymbolOffsetRange::new(start, end)));
}
current_offset += segment.len();
}
symbols
}
fn extract_field_identifier(line: &str) -> Option<String> {
let (candidate, suffix) = line.split_once(':')?;
if suffix.starts_with(':') {
return None;
}
let candidate = candidate.trim();
if candidate.is_empty()
|| !candidate
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
{
return None;
}
Some(candidate.to_string())
}
fn extract_identifier(s: &str) -> Option<String> {
let ident: String = s
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if ident.is_empty() {
None
} else {
Some(ident)
}
}