1pub mod filter;
7pub mod result;
8
9pub use filter::QueryFilter;
10
11use anyhow::{Context, Result};
12use regex::Regex;
13
14use crate::cache::CacheManager;
15use crate::content_store::ContentReader;
16use crate::models::{
17 IndexStatus, IndexWarning, IndexWarningDetails, Language, QueryResponse, SearchResult, Span,
18 SymbolKind,
19};
20use crate::output;
21use crate::parsers::ParserFactory;
22use crate::regex_trigrams::extract_trigrams_from_regex;
23use crate::trigram::TrigramIndex;
24
25pub struct QueryEngine {
27 cache: CacheManager,
28}
29
30impl QueryEngine {
31 pub fn new(cache: CacheManager) -> Self {
33 Self { cache }
34 }
35
36 fn load_dependencies(&self, results: &mut [SearchResult], include_deps: bool) -> Result<()> {
39 if !include_deps || results.is_empty() {
40 return Ok(());
41 }
42
43 log::debug!("Loading dependencies for {} results", results.len());
44
45 let workspace_root = self
49 .cache
50 .path()
51 .parent()
52 .ok_or_else(|| anyhow::anyhow!("Cache path has no parent"))?;
53 let cache_for_deps = CacheManager::new(workspace_root);
54 let dep_index = crate::dependency::DependencyIndex::new(cache_for_deps);
55
56 for result in results {
58 let normalized_path = result.path.strip_prefix("./").unwrap_or(&result.path);
60
61 match self.cache.get_file_id(normalized_path) {
63 Ok(Some(file_id)) => {
64 log::debug!("Found file_id={} for path={}", file_id, result.path);
65 match dep_index.get_dependencies_info(file_id) {
67 Ok(dep_infos) => {
68 log::debug!(
69 "Loaded {} dependencies for file_id={}",
70 dep_infos.len(),
71 file_id
72 );
73 if !dep_infos.is_empty() {
74 result.dependencies = Some(dep_infos);
75 }
76 }
77 Err(e) => {
78 log::warn!("Failed to get dependencies for file_id={}: {}", file_id, e);
79 }
80 }
81 }
82 Ok(None) => {
83 log::warn!("No file_id found for path: {}", result.path);
84 }
85 Err(e) => {
86 log::warn!("Failed to get file_id for path {}: {}", result.path, e);
87 }
88 }
89 }
90
91 Ok(())
92 }
93
94 fn group_and_load_dependencies(
97 &self,
98 results: Vec<SearchResult>,
99 include_deps: bool,
100 context_lines: usize,
101 ) -> Result<Vec<crate::models::FileGroupedResult>> {
102 use crate::models::{FileGroupedResult, MatchResult};
103 use std::collections::HashMap;
104
105 if results.is_empty() {
106 return Ok(Vec::new());
107 }
108
109 let mut grouped: HashMap<String, Vec<SearchResult>> = HashMap::new();
111 for result in results {
112 grouped.entry(result.path.clone()).or_default().push(result);
113 }
114
115 let dep_index = if include_deps {
117 let workspace_root = self
118 .cache
119 .path()
120 .parent()
121 .ok_or_else(|| anyhow::anyhow!("Cache path has no parent"))?;
122 let cache_for_deps = CacheManager::new(workspace_root);
123 Some(crate::dependency::DependencyIndex::new(cache_for_deps))
124 } else {
125 None
126 };
127
128 let content_path = self.cache.path().join("content.bin");
130 let content_reader_opt = ContentReader::open(&content_path).ok();
131
132 let mut file_results: Vec<FileGroupedResult> = grouped
134 .into_iter()
135 .map(|(path, file_matches)| {
136 let language = file_matches.first().map(|r| r.lang).unwrap_or_default();
138
139 let dependencies = if let Some(dep_idx) = &dep_index {
141 let normalized_path = path.strip_prefix("./").unwrap_or(&path);
142 match self.cache.get_file_id(normalized_path) {
143 Ok(Some(file_id)) => match dep_idx.get_dependencies_info(file_id) {
144 Ok(dep_infos) if !dep_infos.is_empty() => {
145 log::debug!(
146 "Loaded {} dependencies for file: {}",
147 dep_infos.len(),
148 path
149 );
150 Some(dep_infos)
151 }
152 Ok(_) => None,
153 Err(e) => {
154 log::warn!("Failed to get dependencies for {}: {}", path, e);
155 None
156 }
157 },
158 Ok(None) => {
159 log::warn!("No file_id found for path: {}", path);
160 None
161 }
162 Err(e) => {
163 log::warn!("Failed to get file_id for path {}: {}", path, e);
164 None
165 }
166 }
167 } else {
168 None
169 };
170
171 let normalized_path = path.strip_prefix("./").unwrap_or(&path);
175 let file_id_for_context = if let Some(reader) = &content_reader_opt {
176 reader.get_file_id_by_path(normalized_path)
177 } else {
178 None
179 };
180 log::debug!(
181 "Context extraction: file={}, file_id={:?}, content_reader={}",
182 path,
183 file_id_for_context,
184 content_reader_opt.is_some()
185 );
186
187 let matches: Vec<MatchResult> = file_matches
189 .into_iter()
190 .map(|r| {
191 let (context_before, context_after) = if context_lines > 0 {
193 if let (Some(reader), Some(fid)) =
194 (&content_reader_opt, file_id_for_context)
195 {
196 let result = reader
197 .get_context_by_line(fid, r.span.start_line, context_lines)
198 .unwrap_or_else(|e| {
199 log::warn!(
200 "Failed to extract context for {}:{}: {}",
201 path,
202 r.span.start_line,
203 e
204 );
205 (vec![], vec![])
206 });
207 log::debug!(
208 "Extracted context for {}:{} - before: {}, after: {}",
209 path,
210 r.span.start_line,
211 result.0.len(),
212 result.1.len()
213 );
214 result
215 } else {
216 if content_reader_opt.is_none() {
217 log::debug!(
218 "No ContentReader available for context extraction"
219 );
220 }
221 if file_id_for_context.is_none() {
222 log::debug!("No file_id found for {}", path);
223 }
224 (vec![], vec![])
225 }
226 } else {
227 (vec![], vec![])
228 };
229
230 MatchResult {
231 kind: r.kind,
232 symbol: r.symbol,
233 span: r.span,
234 preview: r.preview,
235 context_before,
236 context_after,
237 }
238 })
239 .collect();
240
241 FileGroupedResult {
242 path,
243 language,
244 dependencies,
245 matches,
246 }
247 })
248 .collect();
249
250 file_results.sort_by(|a, b| a.path.cmp(&b.path));
252
253 Ok(file_results)
254 }
255
256 pub fn search_with_metadata(
261 &self,
262 pattern: &str,
263 filter: QueryFilter,
264 ) -> Result<QueryResponse> {
265 log::info!(
266 "Executing query with metadata: pattern='{}', filter={:?}",
267 pattern,
268 filter
269 );
270
271 if !self.cache.exists() {
273 anyhow::bail!("Index not found. Run 'rfx index' to build the cache first.");
274 }
275
276 if let Err(e) = self.cache.validate() {
278 anyhow::bail!(
279 "Cache appears to be corrupted: {}. Run 'rfx clear' followed by 'rfx index' to rebuild.",
280 e
281 );
282 }
283
284 let (status, can_trust_results, warning) = self.get_index_status()?;
286
287 let (results, total) = self.search_internal(pattern, filter.clone())?;
289
290 use crate::models::PaginationInfo;
292 let pagination = PaginationInfo {
293 total,
294 count: results.len(),
295 offset: filter.offset.unwrap_or(0),
296 limit: filter.limit,
297 has_more: total > filter.offset.unwrap_or(0) + results.len(),
298 };
299
300 let grouped_results = self.group_and_load_dependencies(
303 results,
304 filter.include_dependencies,
305 filter.context_lines,
306 )?;
307
308 Ok(QueryResponse {
309 ai_instruction: None, status,
311 can_trust_results,
312 warning,
313 pagination,
314 results: grouped_results,
315 })
316 }
317
318 pub fn search(&self, pattern: &str, filter: QueryFilter) -> Result<Vec<SearchResult>> {
323 log::info!(
324 "Executing query: pattern='{}', filter={:?}",
325 pattern,
326 filter
327 );
328
329 if !self.cache.exists() {
331 anyhow::bail!("Index not found. Run 'rfx index' to build the cache first.");
332 }
333
334 if let Err(e) = self.cache.validate() {
336 anyhow::bail!(
337 "Cache appears to be corrupted: {}. Run 'rfx clear' followed by 'rfx index' to rebuild.",
338 e
339 );
340 }
341
342 self.check_index_freshness(&filter)?;
344
345 let (mut results, _total_count) = self.search_internal(pattern, filter.clone())?;
347
348 self.load_dependencies(&mut results, filter.include_dependencies)?;
350
351 Ok(results)
352 }
353
354 fn search_internal(
357 &self,
358 pattern: &str,
359 filter: QueryFilter,
360 ) -> Result<(Vec<SearchResult>, usize)> {
361 use std::time::{Duration, Instant};
362
363 let start_time = Instant::now();
365 let timeout = if filter.timeout_secs > 0 {
366 Some(Duration::from_secs(filter.timeout_secs))
367 } else {
368 None
369 };
370
371 let is_keyword_query = if filter.symbols_mode || filter.kind.is_some() {
385 pattern.is_empty() || ParserFactory::get_all_keywords().contains(&pattern)
386 } else {
387 false
388 };
389
390 let mut filter = filter.clone(); if is_keyword_query
395 && filter.kind.is_none()
396 && let Some(inferred_kind) = Self::keyword_to_kind(pattern)
397 {
398 log::info!(
399 "Keyword '{}' mapped to kind {:?} (auto-inferred)",
400 pattern,
401 inferred_kind
402 );
403 filter.kind = Some(inferred_kind);
404 }
405
406 if !filter.force && !filter.use_regex && !is_keyword_query {
418 let stats = self.cache.stats()?;
419 let total_files = stats.total_files;
420 let pattern_len = pattern.chars().count();
421
422 let large_index_threshold = filter.test_large_index_threshold.unwrap_or(20_000);
427 let short_pattern_threshold = filter.test_short_pattern_threshold.unwrap_or(4);
428
429 if total_files > large_index_threshold && pattern_len < short_pattern_threshold {
430 anyhow::bail!(
431 "Query too broad - would be expensive to execute on this large index\n\
432 \n\
433 This index contains {} files, and pattern '{}' ({} characters) is too short for efficient searching.\n\
434 On large codebases, short patterns can take 10-30+ seconds to complete.\n\
435 \n\
436 This query could:\n\
437 • Hang for an extended period before returning results\n\
438 • Return thousands of results\n\
439 • Flood LLM context windows with excessive data\n\
440 • Fail entirely\n\
441 \n\
442 Suggestions to narrow the query:\n\
443 • Use a longer, more specific pattern (4+ characters recommended for large indexes)\n\
444 • Add a language filter: --lang <language>\n\
445 • Add a file filter: --glob <pattern> or --file <path>\n\
446 • Use --force to bypass this check if you really need all results\n\
447 \n\
448 To force execution anyway:\n\
449 rfx query \"{}\" --force",
450 total_files,
451 pattern,
452 pattern_len,
453 pattern
454 );
455 }
456 }
457
458 let mut results = if is_keyword_query {
460 if let Some(lang) = filter.language {
463 log::info!(
464 "Keyword query detected for '{}' - scanning all {:?} files (bypassing trigram search)",
465 pattern,
466 lang
467 );
468 } else {
469 log::info!(
470 "Keyword query detected for '{}' - scanning all files (bypassing trigram search)",
471 pattern
472 );
473 }
474 self.get_all_language_files(&filter)?
475 } else if filter.use_regex {
476 self.get_regex_candidates(
478 pattern,
479 timeout.as_ref(),
480 &start_time,
481 filter.suppress_output,
482 )?
483 } else {
484 self.get_trigram_candidates(pattern, &filter)?
486 };
487
488 if !is_keyword_query && let Some(lang) = filter.language {
494 let before_count = results.len();
495 results.retain(|r| r.lang == lang);
496 log::debug!(
497 "Language filter ({:?}): reduced {} candidates to {} candidates",
498 lang,
499 before_count,
500 results.len()
501 );
502 }
503
504 if !filter.glob_patterns.is_empty() || !filter.exclude_patterns.is_empty() {
508 use globset::{Glob, GlobSetBuilder};
509
510 let include_matcher = if !filter.glob_patterns.is_empty() {
512 let mut builder = GlobSetBuilder::new();
513 for pattern in &filter.glob_patterns {
514 let normalized = Self::normalize_glob_pattern(pattern);
516 match Glob::new(&normalized) {
517 Ok(glob) => {
518 builder.add(glob);
519 }
520 Err(e) => {
521 log::warn!("Invalid glob pattern '{}': {}", pattern, e);
522 }
523 }
524 }
525 match builder.build() {
526 Ok(matcher) => Some(matcher),
527 Err(e) => {
528 log::warn!("Failed to build glob matcher: {}", e);
529 None
530 }
531 }
532 } else {
533 None
534 };
535
536 let exclude_matcher = if !filter.exclude_patterns.is_empty() {
538 let mut builder = GlobSetBuilder::new();
539 for pattern in &filter.exclude_patterns {
540 let normalized = Self::normalize_glob_pattern(pattern);
542 match Glob::new(&normalized) {
543 Ok(glob) => {
544 builder.add(glob);
545 }
546 Err(e) => {
547 log::warn!("Invalid exclude pattern '{}': {}", pattern, e);
548 }
549 }
550 }
551 match builder.build() {
552 Ok(matcher) => Some(matcher),
553 Err(e) => {
554 log::warn!("Failed to build exclude matcher: {}", e);
555 None
556 }
557 }
558 } else {
559 None
560 };
561
562 let before_count = results.len();
564 results.retain(|r| {
565 let included = if let Some(ref matcher) = include_matcher {
567 matcher.is_match(&r.path)
568 } else {
569 true };
571
572 let excluded = if let Some(ref matcher) = exclude_matcher {
574 matcher.is_match(&r.path)
575 } else {
576 false };
578
579 included && !excluded
580 });
581 log::debug!(
582 "Glob filter: reduced {} candidates to {} candidates",
583 before_count,
584 results.len()
585 );
586 }
587
588 if let Some(timeout_duration) = timeout
590 && start_time.elapsed() > timeout_duration
591 {
592 anyhow::bail!(
593 "Query timeout exceeded ({} seconds).\n\
594 \n\
595 The query took too long to complete. Try one of these approaches:\n\
596 • Use a more specific search pattern (longer patterns = faster search)\n\
597 • Add a language filter with --lang to narrow the search space\n\
598 • Add a file filter with --file to search specific directories\n\
599 • Increase the timeout with --timeout <seconds>\n\
600 \n\
601 Example: rfx query \"{}\" --lang rust --timeout 60",
602 filter.timeout_secs,
603 pattern
604 );
605 }
606
607 if !filter.force {
610 let candidate_count = results.len();
611 let pattern_len = pattern.chars().count();
612
613 let is_short_pattern = pattern_len < 3 && !filter.use_regex && !is_keyword_query;
616
617 let is_broad_ast =
620 filter.use_ast && filter.glob_patterns.is_empty() && candidate_count >= 100;
621
622 let threshold = if filter.use_ast && filter.glob_patterns.is_empty() {
629 100 } else if filter.use_ast {
631 10_000 } else if is_keyword_query {
633 20_000 } else {
635 50_000 };
637
638 let has_many_candidates = candidate_count > threshold
639 && (filter.symbols_mode || filter.kind.is_some() || filter.use_ast);
640
641 if is_short_pattern || has_many_candidates || is_broad_ast {
642 let reason = if is_short_pattern {
643 format!(
644 "Pattern '{}' is too short ({} characters). Short patterns bypass trigram optimization and require scanning many files.",
645 pattern, pattern_len
646 )
647 } else if is_broad_ast {
648 format!(
649 "AST query without --glob restriction will scan the entire codebase ({} files). AST queries are SLOW (500ms-10s+).",
650 candidate_count
651 )
652 } else if is_keyword_query {
653 format!(
654 "Keyword query '{}' matched {} files. This query scans all files of the target language, which will take significant time and produce excessive results.",
655 pattern, candidate_count
656 )
657 } else {
658 format!(
659 "Query matched {} files. Parsing this many files with --symbols or --kind will take significant time and produce excessive results.",
660 candidate_count
661 )
662 };
663
664 let suggestions = if is_short_pattern {
665 vec![
666 "• Use a longer, more specific pattern (3+ characters recommended)",
667 "• Add a language filter: --lang <language>",
668 "• Add a file path filter: --file <path> or --glob <pattern>",
669 "• Use --force to bypass this check if you really need all results",
670 ]
671 } else if is_broad_ast {
672 vec![
673 "• Add --glob to restrict AST query to specific files: --glob 'src/**/*.rs'",
674 "• Use --symbols instead (10-100x faster in 95% of cases)",
675 "• Use --force to bypass this check if you need a full codebase scan",
676 ]
677 } else if is_keyword_query {
678 vec![
679 "• Add a language filter to reduce files scanned: --lang <language>",
680 "• Add glob patterns to search specific directories: --glob 'src/**/*.rs'",
681 "• Add --kind to filter to specific symbol types: --kind function",
682 "• Use a more specific pattern instead of a keyword",
683 "• Use --force to bypass this check if you need all results",
684 ]
685 } else {
686 vec![
687 "• Add a language filter to reduce candidate set: --lang <language>",
688 "• Add glob patterns to search specific directories: --glob 'src/**/*.rs'",
689 "• Use a more specific search pattern",
690 "• Use --force to bypass this check if you need all results",
691 ]
692 };
693
694 let mut cmd_flags = String::new();
696 if filter.symbols_mode {
697 cmd_flags.push_str("--symbols ");
698 }
699 if let Some(ref lang) = filter.language {
700 cmd_flags.push_str(&format!("--lang {:?} ", lang));
701 }
702 if let Some(ref kind) = filter.kind {
703 cmd_flags.push_str(&format!("--kind {:?} ", kind));
704 }
705 if filter.use_ast {
706 cmd_flags.push_str("--ast ");
707 }
708
709 anyhow::bail!(
710 "Query too broad - would be expensive to execute\n\
711 \n\
712 {}\n\
713 \n\
714 This query could:\n\
715 • Hang for an extended period before returning results\n\
716 • Return thousands of results\n\
717 • Flood LLM context windows with excessive data\n\
718 • Fail entirely\n\
719 \n\
720 Suggestions to narrow the query:\n\
721 {}\n\
722 \n\
723 To force execution anyway:\n\
724 rfx query \"{}\" --force {}",
725 reason,
726 suggestions.join("\n "),
727 pattern,
728 cmd_flags
729 );
730 }
731 }
732
733 if filter.symbols_mode || filter.kind.is_some() || filter.use_ast {
736 results.sort_by(|a, b| {
737 a.path
738 .cmp(&b.path)
739 .then_with(|| a.span.start_line.cmp(&b.span.start_line))
740 });
741
742 let candidate_count = results.len();
744 if candidate_count > 1000 && !filter.suppress_output {
745 output::warn(&format!(
746 "Pattern '{}' matched {} files - parsing may take some time. Consider using --file, --glob, or a more specific pattern to narrow the search.",
747 pattern, candidate_count
748 ));
749 } else if candidate_count > 100 {
750 log::info!(
751 "Parsing {} candidate files for symbol extraction",
752 candidate_count
753 );
754 }
755 }
756
757 if filter.use_ast {
759 results = self.enrich_with_ast(results, pattern, filter.language)?;
761 } else if filter.symbols_mode || filter.kind.is_some() {
762 results = self.enrich_with_symbols(results, pattern, &filter)?;
764 }
765
766 if filter.symbols_mode || filter.kind.is_some() {
775 let mut seen = std::collections::HashSet::<(String, usize, Option<String>)>::new();
776 results.retain(|r| seen.insert((r.path.clone(), r.span.start_line, r.symbol.clone())));
777 }
778
779 if let Some(ref kind) = filter.kind {
782 results.retain(|r| {
783 if matches!(kind, SymbolKind::Function) {
784 matches!(r.kind, SymbolKind::Function | SymbolKind::Method)
786 } else {
787 r.kind == *kind
788 }
789 });
790 }
791
792 if let Some(ref file_pattern) = filter.file_pattern {
794 results.retain(|r| r.path.contains(file_pattern));
795 }
796
797 if filter.exact && filter.symbols_mode {
799 results.retain(|r| r.symbol.as_deref() == Some(pattern));
800 }
801
802 if filter.expand {
805 let content_path = self.cache.path().join("content.bin");
807 if let Ok(content_reader) = ContentReader::open(&content_path) {
808 for result in &mut results {
809 if result.span.start_line < result.span.end_line {
811 if let Some(file_id) = Self::find_file_id(&content_reader, &result.path) {
813 if let Ok(content) = content_reader.get_file_content(file_id) {
815 let lines: Vec<&str> = content.lines().collect();
816 let start_idx = result.span.start_line.saturating_sub(1);
817 let end_idx = result.span.end_line.min(lines.len());
818
819 if start_idx < end_idx {
820 let full_body = lines[start_idx..end_idx].join("\n");
821 result.preview = full_body;
822 }
823 }
824 }
825 }
826 }
827 }
828 }
829
830 if filter.paths_only {
832 use std::collections::HashSet;
833 let mut seen_paths = HashSet::new();
834 results.retain(|r| seen_paths.insert(r.path.clone()));
835 }
836
837 results.sort_by(|a, b| {
839 a.path
840 .cmp(&b.path)
841 .then_with(|| a.span.start_line.cmp(&b.span.start_line))
842 });
843
844 let total_count = results.len();
847
848 if let Some(offset) = filter.offset {
850 if offset < results.len() {
851 results = results.into_iter().skip(offset).collect();
852 } else {
853 results.clear();
855 }
856 }
857
858 if let Some(limit) = filter.limit {
860 results.truncate(limit);
861 }
862
863 log::info!(
864 "Query returned {} results (total before pagination: {})",
865 results.len(),
866 total_count
867 );
868
869 Ok((results, total_count))
870 }
871
872 pub fn find_symbol(&self, name: &str) -> Result<Vec<SearchResult>> {
874 let filter = QueryFilter {
875 symbols_mode: true,
876 ..Default::default()
877 };
878 self.search(name, filter)
879 }
880
881 pub fn search_ast(&self, pattern: &str, lang: Option<Language>) -> Result<Vec<SearchResult>> {
883 let filter = QueryFilter {
884 language: lang,
885 use_ast: true,
886 ..Default::default()
887 };
888
889 self.search(pattern, filter)
890 }
891
892 pub fn search_ast_all_files(
913 &self,
914 ast_pattern: &str,
915 filter: QueryFilter,
916 ) -> Result<Vec<SearchResult>> {
917 log::info!(
918 "Executing AST query on all files: pattern='{}', filter={:?}",
919 ast_pattern,
920 filter
921 );
922
923 let lang = filter.language.ok_or_else(|| anyhow::anyhow!(
925 "Language must be specified for AST pattern matching. Use --lang to specify the language.\n\
926 \n\
927 Example: rfx query \"(function_definition) @fn\" --ast --lang python"
928 ))?;
929
930 if !self.cache.exists() {
932 anyhow::bail!("Index not found. Run 'rfx index' to build the cache first.");
933 }
934
935 self.check_index_freshness(&filter)?;
937
938 let content_path = self.cache.path().join("content.bin");
940 let content_reader =
941 ContentReader::open(&content_path).context("Failed to open content store")?;
942
943 use globset::{Glob, GlobSetBuilder};
945
946 let include_matcher = if !filter.glob_patterns.is_empty() {
947 let mut builder = GlobSetBuilder::new();
948 for pattern in &filter.glob_patterns {
949 let normalized = Self::normalize_glob_pattern(pattern);
951 if let Ok(glob) = Glob::new(&normalized) {
952 builder.add(glob);
953 }
954 }
955 builder.build().ok()
956 } else {
957 None
958 };
959
960 let exclude_matcher = if !filter.exclude_patterns.is_empty() {
961 let mut builder = GlobSetBuilder::new();
962 for pattern in &filter.exclude_patterns {
963 let normalized = Self::normalize_glob_pattern(pattern);
965 if let Ok(glob) = Glob::new(&normalized) {
966 builder.add(glob);
967 }
968 }
969 builder.build().ok()
970 } else {
971 None
972 };
973
974 let mut candidates: Vec<SearchResult> = Vec::new();
976
977 for file_id in 0..content_reader.file_count() {
978 let file_path = match content_reader.get_file_path(file_id as u32) {
979 Some(p) => p,
980 None => continue,
981 };
982
983 let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
985 let detected_lang = Language::from_extension(ext);
986
987 if detected_lang != lang {
989 continue;
990 }
991
992 let file_path_str = file_path.to_string_lossy().to_string();
993
994 let included = include_matcher
996 .as_ref()
997 .is_none_or(|m| m.is_match(&file_path_str));
998 let excluded = exclude_matcher
999 .as_ref()
1000 .is_some_and(|m| m.is_match(&file_path_str));
1001
1002 if !included || excluded {
1003 continue;
1004 }
1005
1006 candidates.push(SearchResult {
1008 path: file_path_str,
1009 lang: detected_lang,
1010 span: Span {
1011 start_line: 1,
1012 end_line: 1,
1013 },
1014 symbol: None,
1015 kind: SymbolKind::Unknown("ast_query".to_string()),
1016 preview: String::new(),
1017 dependencies: None,
1018 });
1019 }
1020
1021 log::info!(
1022 "AST query scanning {} files for language {:?}",
1023 candidates.len(),
1024 lang
1025 );
1026
1027 if !filter.force && filter.glob_patterns.is_empty() && candidates.len() >= 100 {
1030 anyhow::bail!(
1031 "Query too broad - would be expensive to execute\n\
1032 \n\
1033 AST query without --glob restriction will scan the ENTIRE codebase ({} files). AST queries are SLOW (500ms-10s+).\n\
1034 \n\
1035 This query could:\n\
1036 • Hang for an extended period before returning results\n\
1037 • Return thousands of results\n\
1038 • Flood LLM context windows with excessive data\n\
1039 • Fail entirely\n\
1040 \n\
1041 Suggestions to narrow the query:\n\
1042 • Add --glob to restrict AST query to specific files: --glob 'src/**/*.rs'\n\
1043 • Use --symbols instead (10-100x faster in 95% of cases)\n\
1044 • Use --force to bypass this check if you need a full codebase scan\n\
1045 \n\
1046 To force execution anyway:\n\
1047 rfx query \"{}\" --force --ast --lang {:?}",
1048 candidates.len(),
1049 ast_pattern,
1050 lang
1051 );
1052 }
1053
1054 if candidates.is_empty() {
1055 if !filter.suppress_output {
1056 output::warn(&format!(
1057 "No files found for language {:?}. Check your language filter or glob patterns.",
1058 lang
1059 ));
1060 }
1061 return Ok(Vec::new());
1062 }
1063
1064 let mut results = self.enrich_with_ast(candidates, ast_pattern, filter.language)?;
1067
1068 log::debug!("AST query found {} matches before filtering", results.len());
1069
1070 if let Some(ref kind) = filter.kind {
1074 results.retain(|r| {
1075 if matches!(kind, SymbolKind::Function) {
1076 matches!(r.kind, SymbolKind::Function | SymbolKind::Method)
1077 } else {
1078 r.kind == *kind
1079 }
1080 });
1081 }
1082
1083 if filter.expand {
1087 let content_path = self.cache.path().join("content.bin");
1088 if let Ok(content_reader) = ContentReader::open(&content_path) {
1089 for result in &mut results {
1090 if result.span.start_line < result.span.end_line
1091 && let Some(file_id) = Self::find_file_id(&content_reader, &result.path)
1092 && let Ok(content) = content_reader.get_file_content(file_id)
1093 {
1094 let lines: Vec<&str> = content.lines().collect();
1095 let start_idx = result.span.start_line.saturating_sub(1);
1096 let end_idx = result.span.end_line.min(lines.len());
1097
1098 if start_idx < end_idx {
1099 let full_body = lines[start_idx..end_idx].join("\n");
1100 result.preview = full_body;
1101 }
1102 }
1103 }
1104 }
1105 }
1106
1107 if filter.paths_only {
1109 use std::collections::HashSet;
1110 let mut seen_paths = HashSet::new();
1111 results.retain(|r| seen_paths.insert(r.path.clone()));
1112 }
1113
1114 results.sort_by(|a, b| {
1116 a.path
1117 .cmp(&b.path)
1118 .then_with(|| a.span.start_line.cmp(&b.span.start_line))
1119 });
1120
1121 if let Some(offset) = filter.offset {
1123 if offset < results.len() {
1124 results = results.into_iter().skip(offset).collect();
1125 } else {
1126 results.clear();
1127 }
1128 }
1129
1130 if let Some(limit) = filter.limit {
1132 results.truncate(limit);
1133 }
1134
1135 log::info!("AST query returned {} results", results.len());
1136
1137 self.load_dependencies(&mut results, filter.include_dependencies)?;
1139
1140 Ok(results)
1141 }
1142
1143 pub fn search_ast_with_text_filter(
1155 &self,
1156 text_pattern: &str,
1157 ast_pattern: &str,
1158 filter: QueryFilter,
1159 ) -> Result<Vec<SearchResult>> {
1160 log::info!(
1161 "Executing AST query with text filter: text='{}', ast='{}', filter={:?}",
1162 text_pattern,
1163 ast_pattern,
1164 filter
1165 );
1166
1167 if !self.cache.exists() {
1169 anyhow::bail!("Index not found. Run 'rfx index' to build the cache first.");
1170 }
1171
1172 self.check_index_freshness(&filter)?;
1174
1175 use std::time::{Duration, Instant};
1177 let start_time = Instant::now();
1178 let timeout = if filter.timeout_secs > 0 {
1179 Some(Duration::from_secs(filter.timeout_secs))
1180 } else {
1181 None
1182 };
1183
1184 let candidates = if filter.use_regex {
1186 self.get_regex_candidates(
1187 text_pattern,
1188 timeout.as_ref(),
1189 &start_time,
1190 filter.suppress_output,
1191 )?
1192 } else {
1193 self.get_trigram_candidates(text_pattern, &filter)?
1194 };
1195
1196 log::debug!("Phase 1 found {} candidate locations", candidates.len());
1197
1198 let mut results = self.enrich_with_ast(candidates, ast_pattern, filter.language)?;
1200
1201 log::debug!("Phase 2 AST matching found {} results", results.len());
1202
1203 if let Some(lang) = filter.language {
1205 results.retain(|r| r.lang == lang);
1206 }
1207
1208 if let Some(ref kind) = filter.kind {
1209 results.retain(|r| {
1210 if matches!(kind, SymbolKind::Function) {
1211 matches!(r.kind, SymbolKind::Function | SymbolKind::Method)
1212 } else {
1213 r.kind == *kind
1214 }
1215 });
1216 }
1217
1218 if let Some(ref file_pattern) = filter.file_pattern {
1219 results.retain(|r| r.path.contains(file_pattern));
1220 }
1221
1222 if !filter.glob_patterns.is_empty() || !filter.exclude_patterns.is_empty() {
1224 use globset::{Glob, GlobSetBuilder};
1225
1226 let include_matcher = if !filter.glob_patterns.is_empty() {
1227 let mut builder = GlobSetBuilder::new();
1228 for pattern in &filter.glob_patterns {
1229 let normalized = Self::normalize_glob_pattern(pattern);
1231 if let Ok(glob) = Glob::new(&normalized) {
1232 builder.add(glob);
1233 }
1234 }
1235 builder.build().ok()
1236 } else {
1237 None
1238 };
1239
1240 let exclude_matcher = if !filter.exclude_patterns.is_empty() {
1241 let mut builder = GlobSetBuilder::new();
1242 for pattern in &filter.exclude_patterns {
1243 let normalized = Self::normalize_glob_pattern(pattern);
1245 if let Ok(glob) = Glob::new(&normalized) {
1246 builder.add(glob);
1247 }
1248 }
1249 builder.build().ok()
1250 } else {
1251 None
1252 };
1253
1254 results.retain(|r| {
1255 let included = include_matcher.as_ref().is_none_or(|m| m.is_match(&r.path));
1256 let excluded = exclude_matcher
1257 .as_ref()
1258 .is_some_and(|m| m.is_match(&r.path));
1259 included && !excluded
1260 });
1261 }
1262
1263 if filter.exact && filter.symbols_mode {
1264 results.retain(|r| r.symbol.as_deref() == Some(text_pattern));
1265 }
1266
1267 if filter.expand {
1269 let content_path = self.cache.path().join("content.bin");
1270 if let Ok(content_reader) = ContentReader::open(&content_path) {
1271 for result in &mut results {
1272 if result.span.start_line < result.span.end_line
1273 && let Some(file_id) = Self::find_file_id(&content_reader, &result.path)
1274 && let Ok(content) = content_reader.get_file_content(file_id)
1275 {
1276 let lines: Vec<&str> = content.lines().collect();
1277 let start_idx = result.span.start_line.saturating_sub(1);
1278 let end_idx = result.span.end_line.min(lines.len());
1279
1280 if start_idx < end_idx {
1281 let full_body = lines[start_idx..end_idx].join("\n");
1282 result.preview = full_body;
1283 }
1284 }
1285 }
1286 }
1287 }
1288
1289 results.sort_by(|a, b| {
1291 a.path
1292 .cmp(&b.path)
1293 .then_with(|| a.span.start_line.cmp(&b.span.start_line))
1294 });
1295
1296 if let Some(offset) = filter.offset {
1298 if offset < results.len() {
1299 results = results.into_iter().skip(offset).collect();
1300 } else {
1301 results.clear();
1302 }
1303 }
1304
1305 if let Some(limit) = filter.limit {
1307 results.truncate(limit);
1308 }
1309
1310 log::info!("AST query returned {} results", results.len());
1311
1312 Ok(results)
1313 }
1314
1315 pub fn list_by_kind(&self, kind: SymbolKind) -> Result<Vec<SearchResult>> {
1317 let filter = QueryFilter {
1318 kind: Some(kind),
1319 symbols_mode: true,
1320 ..Default::default()
1321 };
1322
1323 self.search("*", filter)
1324 }
1325
1326 fn enrich_with_symbols(
1347 &self,
1348 candidates: Vec<SearchResult>,
1349 pattern: &str,
1350 filter: &QueryFilter,
1351 ) -> Result<Vec<SearchResult>> {
1352 let content_path = self.cache.path().join("content.bin");
1354 let content_reader =
1355 ContentReader::open(&content_path).context("Failed to open content store")?;
1356
1357 let trigrams_path = self.cache.path().join("trigrams.bin");
1359 let trigram_index = if trigrams_path.exists() {
1360 TrigramIndex::load(&trigrams_path)?
1361 } else {
1362 Self::rebuild_trigram_index(&content_reader)?
1363 };
1364
1365 let symbol_cache = crate::symbol_cache::SymbolCache::open(self.cache.path())
1367 .context("Failed to open symbol cache")?;
1368
1369 let root = self.cache.workspace_root();
1371 let branch =
1372 crate::git::get_current_branch(&root).unwrap_or_else(|_| "_default".to_string());
1373 let file_hashes = self
1374 .cache
1375 .load_hashes_for_branch(&branch)
1376 .context("Failed to load file hashes")?;
1377 log::debug!(
1378 "Loaded {} file hashes for branch '{}' for symbol cache lookups",
1379 file_hashes.len(),
1380 branch
1381 );
1382
1383 use std::collections::HashMap;
1385 let mut files_by_path: HashMap<String, Vec<SearchResult>> = HashMap::new();
1386 let mut skipped_unsupported = 0;
1387
1388 for candidate in candidates {
1389 if !candidate.lang.is_supported() {
1391 skipped_unsupported += 1;
1392 continue;
1393 }
1394
1395 files_by_path
1396 .entry(candidate.path.clone())
1397 .or_default()
1398 .push(candidate);
1399 }
1400
1401 let total_files = files_by_path.len();
1402 log::debug!(
1403 "Processing {} candidate files for symbol enrichment (skipped {} unsupported language files)",
1404 total_files,
1405 skipped_unsupported
1406 );
1407
1408 if total_files > 1000 && !filter.suppress_output {
1410 output::warn(&format!(
1411 "Pattern '{}' matched {} files. This may take some time to parse. Consider using a more specific pattern or adding --lang/--file filters to narrow the search.",
1412 pattern, total_files
1413 ));
1414 }
1415
1416 let mut files_to_process: Vec<String> = files_by_path.keys().cloned().collect();
1418
1419 let mut files_to_skip: std::collections::HashSet<String> = std::collections::HashSet::new();
1422
1423 for file_path in &files_to_process {
1424 let ext = std::path::Path::new(file_path)
1426 .extension()
1427 .and_then(|e| e.to_str())
1428 .unwrap_or("");
1429 let lang = Language::from_extension(ext);
1430
1431 if let Some(line_filter) = crate::line_filter::get_filter(lang) {
1433 let file_id =
1435 match Self::find_file_id_by_path(&content_reader, &trigram_index, file_path) {
1436 Some(id) => id,
1437 None => continue,
1438 };
1439
1440 let content = match content_reader.get_file_content(file_id) {
1442 Ok(c) => c,
1443 Err(_) => continue,
1444 };
1445
1446 let mut all_in_non_code = true;
1448 for line in content.lines() {
1449 let mut search_start = 0;
1451 while let Some(pos) = line[search_start..].find(pattern) {
1452 let absolute_pos = search_start + pos;
1453
1454 let in_comment = line_filter.is_in_comment(line, absolute_pos);
1456 let in_string = line_filter.is_in_string(line, absolute_pos);
1457
1458 if !in_comment && !in_string {
1459 all_in_non_code = false;
1461 break;
1462 }
1463
1464 search_start = absolute_pos + pattern.len();
1465 }
1466
1467 if !all_in_non_code {
1468 break;
1469 }
1470 }
1471
1472 if all_in_non_code {
1474 if content.contains(pattern) {
1476 files_to_skip.insert(file_path.clone());
1477 log::debug!(
1478 "Pre-filter: Skipping {} (all matches in comments/strings)",
1479 file_path
1480 );
1481 }
1482 }
1483 }
1484 }
1485
1486 files_to_process.retain(|path| !files_to_skip.contains(path));
1488
1489 log::debug!(
1490 "Pre-filter: Skipped {} files where all matches are in comments/strings (parsing {} files)",
1491 files_to_skip.len(),
1492 files_to_process.len()
1493 );
1494
1495 let num_threads = {
1497 let available_cores = std::thread::available_parallelism()
1498 .map(|n| n.get())
1499 .unwrap_or(4);
1500 ((available_cores as f64 * 0.8).ceil() as usize).clamp(1, 8)
1503 };
1504
1505 log::debug!(
1506 "Using {} threads for parallel symbol extraction (out of {} available cores)",
1507 num_threads,
1508 std::thread::available_parallelism()
1509 .map(|n| n.get())
1510 .unwrap_or(4)
1511 );
1512
1513 let pool = rayon::ThreadPoolBuilder::new()
1515 .num_threads(num_threads)
1516 .build()
1517 .context("Failed to create thread pool for symbol extraction")?;
1518
1519 let files_with_hashes: Vec<String> = files_to_process
1524 .iter()
1525 .filter(|path| file_hashes.contains_key(path.as_str()))
1526 .cloned()
1527 .collect();
1528
1529 let file_id_map = self
1531 .cache
1532 .batch_get_file_ids(&files_with_hashes)
1533 .context("Failed to batch lookup file IDs")?;
1534
1535 let file_lookup_tuples: Vec<(i64, String, String)> = files_with_hashes
1537 .iter()
1538 .filter_map(|path| {
1539 let file_id = file_id_map.get(path)?;
1540 let hash = file_hashes.get(path.as_str())?;
1541 Some((*file_id, hash.clone(), path.clone()))
1542 })
1543 .collect();
1544
1545 let batch_results = symbol_cache
1547 .batch_get_with_kind(&file_lookup_tuples, filter.kind.clone())
1548 .context("Failed to batch read symbol cache")?;
1549
1550 let mut cached_symbols: HashMap<String, Vec<SearchResult>> = HashMap::new();
1552 let mut files_needing_parse: Vec<String> = Vec::new();
1553
1554 let id_to_path: HashMap<i64, String> = file_id_map
1556 .iter()
1557 .map(|(path, id)| (*id, path.clone()))
1558 .collect();
1559
1560 for (file_id, symbols) in batch_results {
1562 if let Some(file_path) = id_to_path.get(&file_id) {
1563 cached_symbols.insert(file_path.clone(), symbols);
1564 }
1565 }
1566
1567 for path in &files_with_hashes {
1569 if file_id_map.contains_key(path) && !cached_symbols.contains_key(path) {
1570 files_needing_parse.push(path.clone());
1571 }
1572 }
1573
1574 for file_path in &files_to_process {
1576 if !file_hashes.contains_key(file_path.as_str()) {
1577 files_needing_parse.push(file_path.clone());
1578 }
1579 }
1580
1581 log::debug!(
1582 "Symbol cache: {} hits, {} need parsing",
1583 cached_symbols.len(),
1584 files_needing_parse.len()
1585 );
1586
1587 use rayon::prelude::*;
1589
1590 let parsed_symbols: Vec<SearchResult> = pool.install(|| {
1591 files_needing_parse
1592 .par_iter()
1593 .flat_map(|file_path| {
1594 let file_id = match Self::find_file_id_by_path(
1596 &content_reader,
1597 &trigram_index,
1598 file_path,
1599 ) {
1600 Some(id) => id,
1601 None => {
1602 log::warn!("Could not find file_id for path: {}", file_path);
1603 return Vec::new();
1604 }
1605 };
1606
1607 let content = match content_reader.get_file_content(file_id) {
1608 Ok(c) => c,
1609 Err(e) => {
1610 log::warn!("Failed to read file {}: {}", file_path, e);
1611 return Vec::new();
1612 }
1613 };
1614
1615 let ext = std::path::Path::new(file_path)
1617 .extension()
1618 .and_then(|e| e.to_str())
1619 .unwrap_or("");
1620 let lang = Language::from_extension(ext);
1621
1622 let symbols = match ParserFactory::parse(file_path, content, lang) {
1624 Ok(symbols) => {
1625 log::debug!("Parsed {} symbols from {}", symbols.len(), file_path);
1626 symbols
1627 }
1628 Err(e) => {
1629 log::debug!("Failed to parse {}: {}", file_path, e);
1630 Vec::new()
1631 }
1632 };
1633
1634 if let Some(file_hash) = file_hashes.get(file_path.as_str())
1636 && let Err(e) = symbol_cache.set(file_path, file_hash, &symbols)
1637 {
1638 log::debug!("Failed to cache symbols for {}: {}", file_path, e);
1639 }
1640
1641 symbols
1642 })
1643 .collect()
1644 });
1645
1646 let mut all_symbols: Vec<SearchResult> = Vec::new();
1648
1649 for symbols in cached_symbols.values() {
1651 all_symbols.extend_from_slice(symbols);
1652 }
1653
1654 all_symbols.extend(parsed_symbols);
1656
1657 let is_keyword_query = {
1665 let lang_to_check = if let Some(lang) = filter.language {
1667 vec![lang]
1670 } else {
1671 let mut langs: Vec<Language> =
1675 all_symbols.iter().map(|s| s.lang).collect::<Vec<_>>();
1676 langs.sort_by(|a, b| format!("{:?}", a).cmp(&format!("{:?}", b))); langs.dedup(); langs
1679 };
1680
1681 lang_to_check
1683 .iter()
1684 .any(|lang| ParserFactory::get_keywords(*lang).contains(&pattern))
1685 };
1686
1687 let filtered: Vec<SearchResult> = if is_keyword_query {
1690 log::info!(
1691 "Pattern '{}' is a language keyword - listing all symbols (kind filtering will be applied in Phase 3)",
1692 pattern
1693 );
1694 all_symbols
1695 } else if filter.use_regex {
1696 use std::collections::{HashMap, HashSet};
1702 let mut candidate_lines: HashMap<String, HashSet<usize>> = HashMap::new();
1703 for candidate in &files_by_path {
1704 for cand in candidate.1 {
1705 candidate_lines
1706 .entry(candidate.0.clone())
1707 .or_default()
1708 .insert(cand.span.start_line);
1709 }
1710 }
1711
1712 all_symbols
1714 .into_iter()
1715 .filter(|sym| {
1716 if let Some(lines) = candidate_lines.get(&sym.path) {
1717 for line in sym.span.start_line..=sym.span.end_line {
1719 if lines.contains(&line) {
1720 return true;
1721 }
1722 }
1723 }
1724 false
1725 })
1726 .collect()
1727 } else if filter.use_contains {
1728 all_symbols
1730 .into_iter()
1731 .filter(|sym| sym.symbol.as_deref().is_some_and(|s| s.contains(pattern)))
1732 .collect()
1733 } else {
1734 all_symbols
1736 .into_iter()
1737 .filter(|sym| sym.symbol.as_deref() == Some(pattern))
1738 .collect()
1739 };
1740
1741 log::info!(
1742 "Symbol enrichment found {} matches for pattern '{}'",
1743 filtered.len(),
1744 pattern
1745 );
1746
1747 Ok(filtered)
1748 }
1749
1750 fn enrich_with_ast(
1769 &self,
1770 candidates: Vec<SearchResult>,
1771 ast_pattern: &str,
1772 language: Option<Language>,
1773 ) -> Result<Vec<SearchResult>> {
1774 let lang = language.ok_or_else(|| anyhow::anyhow!(
1776 "Language must be specified for AST pattern matching. Use --lang to specify the language."
1777 ))?;
1778
1779 let content_path = self.cache.path().join("content.bin");
1781 let content_reader =
1782 ContentReader::open(&content_path).context("Failed to open content store")?;
1783
1784 let trigrams_path = self.cache.path().join("trigrams.bin");
1786 let trigram_index = if trigrams_path.exists() {
1787 TrigramIndex::load(&trigrams_path)?
1788 } else {
1789 Self::rebuild_trigram_index(&content_reader)?
1790 };
1791
1792 use std::collections::HashMap;
1794 let mut file_contents: HashMap<String, String> = HashMap::new();
1795
1796 for candidate in &candidates {
1797 if file_contents.contains_key(&candidate.path) {
1798 continue;
1799 }
1800
1801 let file_id = match Self::find_file_id_by_path(
1803 &content_reader,
1804 &trigram_index,
1805 &candidate.path,
1806 ) {
1807 Some(id) => id,
1808 None => {
1809 log::warn!("Could not find file_id for path: {}", candidate.path);
1810 continue;
1811 }
1812 };
1813
1814 let content = match content_reader.get_file_content(file_id) {
1816 Ok(c) => c,
1817 Err(e) => {
1818 log::warn!("Failed to read file {}: {}", candidate.path, e);
1819 continue;
1820 }
1821 };
1822
1823 file_contents.insert(candidate.path.clone(), content.to_string());
1824 }
1825
1826 log::debug!(
1827 "Executing AST query on {} candidate files with language {:?}",
1828 file_contents.len(),
1829 lang
1830 );
1831
1832 let results =
1834 crate::ast_query::execute_ast_query(candidates, ast_pattern, lang, &file_contents)?;
1835
1836 log::info!(
1837 "AST query found {} matches for pattern '{}'",
1838 results.len(),
1839 ast_pattern
1840 );
1841
1842 Ok(results)
1843 }
1844
1845 fn find_file_id_by_path(
1847 content_reader: &ContentReader,
1848 trigram_index: &TrigramIndex,
1849 target_path: &str,
1850 ) -> Option<u32> {
1851 for file_id in 0..trigram_index.file_count() {
1853 if let Some(path) = trigram_index.get_file(file_id as u32)
1854 && path.to_string_lossy() == target_path
1855 {
1856 return Some(file_id as u32);
1857 }
1858 }
1859
1860 for file_id in 0..content_reader.file_count() {
1862 if let Some(path) = content_reader.get_file_path(file_id as u32)
1863 && path.to_string_lossy() == target_path
1864 {
1865 return Some(file_id as u32);
1866 }
1867 }
1868
1869 None
1870 }
1871
1872 fn keyword_to_kind(keyword: &str) -> Option<SymbolKind> {
1880 filter::keyword_to_kind(keyword)
1881 }
1882
1883 fn get_all_language_files(&self, filter: &QueryFilter) -> Result<Vec<SearchResult>> {
1891 let content_path = self.cache.path().join("content.bin");
1896 let content_reader =
1897 ContentReader::open(&content_path).context("Failed to open content store")?;
1898
1899 use globset::{Glob, GlobSetBuilder};
1901
1902 let include_matcher = if !filter.glob_patterns.is_empty() {
1903 let mut builder = GlobSetBuilder::new();
1904 for pattern in &filter.glob_patterns {
1905 let normalized = Self::normalize_glob_pattern(pattern);
1906 if let Ok(glob) = Glob::new(&normalized) {
1907 builder.add(glob);
1908 }
1909 }
1910 builder.build().ok()
1911 } else {
1912 None
1913 };
1914
1915 let exclude_matcher = if !filter.exclude_patterns.is_empty() {
1916 let mut builder = GlobSetBuilder::new();
1917 for pattern in &filter.exclude_patterns {
1918 let normalized = Self::normalize_glob_pattern(pattern);
1919 if let Ok(glob) = Glob::new(&normalized) {
1920 builder.add(glob);
1921 }
1922 }
1923 builder.build().ok()
1924 } else {
1925 None
1926 };
1927
1928 let mut candidates: Vec<SearchResult> = Vec::new();
1930
1931 for file_id in 0..content_reader.file_count() {
1932 let file_path = match content_reader.get_file_path(file_id as u32) {
1933 Some(p) => p,
1934 None => continue,
1935 };
1936
1937 let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
1939 let detected_lang = Language::from_extension(ext);
1940
1941 if let Some(lang) = filter.language
1943 && detected_lang != lang
1944 {
1945 continue;
1946 }
1947
1948 let file_path_str = file_path.to_string_lossy().to_string();
1949
1950 let included = include_matcher
1952 .as_ref()
1953 .is_none_or(|m| m.is_match(&file_path_str));
1954 let excluded = exclude_matcher
1955 .as_ref()
1956 .is_some_and(|m| m.is_match(&file_path_str));
1957
1958 if !included || excluded {
1959 continue;
1960 }
1961
1962 if let Some(ref file_pattern) = filter.file_pattern
1964 && !file_path_str.contains(file_pattern)
1965 {
1966 continue;
1967 }
1968
1969 candidates.push(SearchResult {
1972 path: file_path_str,
1973 lang: detected_lang,
1974 span: Span {
1975 start_line: 1,
1976 end_line: 1,
1977 },
1978 symbol: None,
1979 kind: SymbolKind::Unknown("keyword_query".to_string()),
1980 preview: String::new(),
1981 dependencies: None,
1982 });
1983 }
1984
1985 if let Some(lang) = filter.language {
1986 log::info!(
1987 "Keyword query will scan {} {:?} files for symbol extraction",
1988 candidates.len(),
1989 lang
1990 );
1991 } else {
1992 log::info!(
1993 "Keyword query will scan {} files (all languages) for symbol extraction",
1994 candidates.len()
1995 );
1996 }
1997
1998 Ok(candidates)
1999 }
2000
2001 fn get_trigram_candidates(
2003 &self,
2004 pattern: &str,
2005 filter: &QueryFilter,
2006 ) -> Result<Vec<SearchResult>> {
2007 let content_path = self.cache.path().join("content.bin");
2009 let content_reader =
2010 ContentReader::open(&content_path).context("Failed to open content store")?;
2011
2012 if pattern.chars().count() < 3 {
2016 log::info!(
2017 "Pattern '{}' is shorter than 3 chars — trigram index cannot be used, \
2018 falling back to linear scan",
2019 pattern
2020 );
2021 return self.linear_scan_candidates(pattern, filter, &content_reader);
2022 }
2023
2024 let trigrams_path = self.cache.path().join("trigrams.bin");
2026 let trigram_index = if trigrams_path.exists() {
2027 match TrigramIndex::load(&trigrams_path) {
2028 Ok(index) => {
2029 log::debug!(
2030 "Loaded trigram index from disk: {} trigrams, {} files",
2031 index.trigram_count(),
2032 index.file_count()
2033 );
2034 index
2035 }
2036 Err(e) => {
2037 log::warn!("Failed to load trigram index from disk: {}", e);
2038 log::warn!("Rebuilding trigram index from content store...");
2039 Self::rebuild_trigram_index(&content_reader)?
2040 }
2041 }
2042 } else {
2043 log::debug!("trigrams.bin not found, rebuilding from content store");
2044 Self::rebuild_trigram_index(&content_reader)?
2045 };
2046
2047 let candidates = trigram_index.search(pattern);
2049 log::debug!(
2050 "Found {} candidate locations from trigram search",
2051 candidates.len()
2052 );
2053
2054 let pattern_owned = pattern.to_string();
2056
2057 let compiled_regex = if filter.use_regex {
2059 match Regex::new(&pattern_owned) {
2060 Ok(re) => Some(re),
2061 Err(e) => {
2062 log::error!("Invalid regex pattern '{}': {}", pattern_owned, e);
2063 anyhow::bail!("Invalid regex pattern '{}': {}", pattern_owned, e);
2064 }
2065 }
2066 } else {
2067 None
2068 };
2069
2070 use std::collections::HashMap;
2072 let mut candidates_by_file: HashMap<u32, Vec<crate::trigram::FileLocation>> =
2073 HashMap::new();
2074 for loc in candidates {
2075 candidates_by_file.entry(loc.file_id).or_default().push(loc);
2076 }
2077
2078 log::debug!(
2079 "Scanning {} files with trigram matches",
2080 candidates_by_file.len()
2081 );
2082
2083 use rayon::prelude::*;
2085
2086 let results: Vec<SearchResult> = candidates_by_file
2087 .par_iter()
2088 .flat_map(|(file_id, locations)| {
2089 let file_path = match trigram_index.get_file(*file_id) {
2091 Some(p) => p,
2092 None => return Vec::new(),
2093 };
2094
2095 let content = match content_reader.get_file_content(*file_id) {
2096 Ok(c) => c,
2097 Err(_) => return Vec::new(),
2098 };
2099
2100 let file_path_str = file_path.to_string_lossy().to_string();
2101
2102 let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
2104 let lang = Language::from_extension(ext);
2105
2106 let lines: Vec<&str> = content.lines().collect();
2108
2109 let mut seen_lines: std::collections::HashSet<usize> =
2111 std::collections::HashSet::new();
2112 let mut file_results = Vec::new();
2113
2114 for loc in locations {
2116 let line_no = loc.line_no as usize;
2117
2118 if seen_lines.contains(&line_no) {
2120 continue;
2121 }
2122
2123 if line_no == 0 || line_no > lines.len() {
2125 log::debug!(
2126 "Line {} out of bounds (file has {} lines)",
2127 line_no,
2128 lines.len()
2129 );
2130 continue;
2131 }
2132
2133 let line = lines[line_no - 1];
2134
2135 let line_matches = if filter.use_regex {
2140 compiled_regex
2143 .as_ref()
2144 .map(|re| re.is_match(line))
2145 .unwrap_or(false)
2146 } else if filter.use_contains {
2147 line.contains(&pattern_owned)
2149 } else {
2150 Self::has_word_boundary_match(line, &pattern_owned)
2152 };
2153
2154 if !line_matches {
2155 continue;
2156 }
2157
2158 seen_lines.insert(line_no);
2159
2160 file_results.push(SearchResult {
2162 path: file_path_str.clone(),
2163 lang,
2164 kind: SymbolKind::Unknown("text_match".to_string()),
2165 symbol: None, span: Span {
2167 start_line: line_no,
2168 end_line: line_no,
2169 },
2170 preview: line.to_string(),
2171 dependencies: None,
2172 });
2173 }
2174
2175 file_results
2176 })
2177 .collect();
2178
2179 Ok(results)
2180 }
2181
2182 fn linear_scan_candidates(
2189 &self,
2190 pattern: &str,
2191 filter: &QueryFilter,
2192 content_reader: &ContentReader,
2193 ) -> Result<Vec<SearchResult>> {
2194 use rayon::prelude::*;
2195
2196 let pattern_owned = pattern.to_string();
2197 let file_count = content_reader.file_count();
2198
2199 let compiled_regex = if filter.use_regex {
2200 match Regex::new(&pattern_owned) {
2201 Ok(re) => Some(re),
2202 Err(e) => anyhow::bail!("Invalid regex pattern '{}': {}", pattern_owned, e),
2203 }
2204 } else {
2205 None
2206 };
2207
2208 let results: Vec<SearchResult> = (0..file_count as u32)
2209 .collect::<Vec<_>>()
2210 .par_iter()
2211 .flat_map(|&file_id| {
2212 let file_path = match content_reader.get_file_path(file_id) {
2213 Some(p) => p.to_path_buf(),
2214 None => return Vec::new(),
2215 };
2216 let content = match content_reader.get_file_content(file_id) {
2217 Ok(c) => c,
2218 Err(_) => return Vec::new(),
2219 };
2220
2221 let file_path_str = file_path.to_string_lossy().to_string();
2222 let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
2223 let lang = Language::from_extension(ext);
2224
2225 let mut seen_lines = std::collections::HashSet::new();
2226 let mut file_results = Vec::new();
2227
2228 for (line_idx, line) in content.lines().enumerate() {
2229 let line_no = line_idx + 1;
2230 if seen_lines.contains(&line_no) {
2231 continue;
2232 }
2233
2234 let line_matches = if filter.use_regex {
2235 compiled_regex
2236 .as_ref()
2237 .map(|re| re.is_match(line))
2238 .unwrap_or(false)
2239 } else if filter.use_contains {
2240 line.contains(&pattern_owned)
2241 } else {
2242 Self::has_word_boundary_match(line, &pattern_owned)
2243 };
2244
2245 if !line_matches {
2246 continue;
2247 }
2248
2249 seen_lines.insert(line_no);
2250 file_results.push(SearchResult {
2251 path: file_path_str.clone(),
2252 lang,
2253 kind: SymbolKind::Unknown("text_match".to_string()),
2254 symbol: None,
2255 span: Span {
2256 start_line: line_no,
2257 end_line: line_no,
2258 },
2259 preview: line.to_string(),
2260 dependencies: None,
2261 });
2262 }
2263
2264 file_results
2265 })
2266 .collect();
2267
2268 log::info!(
2269 "Linear scan (short pattern '{}') found {} results across {} files",
2270 pattern,
2271 results.len(),
2272 file_count
2273 );
2274 Ok(results)
2275 }
2276
2277 fn get_regex_candidates(
2301 &self,
2302 pattern: &str,
2303 timeout: Option<&std::time::Duration>,
2304 start_time: &std::time::Instant,
2305 suppress_output: bool,
2306 ) -> Result<Vec<SearchResult>> {
2307 let regex =
2309 Regex::new(pattern).with_context(|| format!("Invalid regex pattern: {}", pattern))?;
2310
2311 if let Some(timeout_duration) = timeout
2313 && start_time.elapsed() > *timeout_duration
2314 {
2315 anyhow::bail!(
2316 "Query timeout exceeded ({} seconds) during regex compilation",
2317 timeout_duration.as_secs()
2318 );
2319 }
2320
2321 let trigrams = extract_trigrams_from_regex(pattern);
2323
2324 let content_path = self.cache.path().join("content.bin");
2326 let content_reader =
2327 ContentReader::open(&content_path).context("Failed to open content store")?;
2328
2329 let mut results = Vec::new();
2330
2331 if trigrams.is_empty() {
2332 if !suppress_output {
2334 output::warn(&format!(
2335 "Regex pattern '{}' has no literals (≥3 chars), falling back to full content scan. This may be slow on large codebases. Consider using patterns with literal text.",
2336 pattern
2337 ));
2338 }
2339
2340 for file_id in 0..content_reader.file_count() {
2342 let file_path = content_reader
2343 .get_file_path(file_id as u32)
2344 .context("Invalid file_id")?;
2345 let content = content_reader.get_file_content(file_id as u32)?;
2346
2347 self.find_regex_matches_in_file(®ex, file_path, content, &mut results)?;
2348 }
2349 } else {
2350 log::debug!(
2352 "Using {} trigrams to narrow regex search candidates",
2353 trigrams.len()
2354 );
2355
2356 let trigrams_path = self.cache.path().join("trigrams.bin");
2358 let trigram_index = if trigrams_path.exists() {
2359 TrigramIndex::load(&trigrams_path)?
2360 } else {
2361 Self::rebuild_trigram_index(&content_reader)?
2362 };
2363
2364 use crate::regex_trigrams::extract_literal_sequences;
2366 let literals = extract_literal_sequences(pattern);
2367
2368 if literals.is_empty() {
2369 log::warn!(
2370 "Regex extraction found trigrams but no literal sequences - this shouldn't happen"
2371 );
2372 for file_id in 0..content_reader.file_count() {
2374 let file_path = content_reader
2375 .get_file_path(file_id as u32)
2376 .context("Invalid file_id")?;
2377 let content = content_reader.get_file_content(file_id as u32)?;
2378 self.find_regex_matches_in_file(®ex, file_path, content, &mut results)?;
2379 }
2380 } else {
2381 use std::collections::HashSet;
2386 let mut candidate_files: HashSet<u32> = HashSet::new();
2387
2388 for literal in &literals {
2389 let candidates = trigram_index.search(literal);
2391 let file_ids: HashSet<u32> = candidates.iter().map(|loc| loc.file_id).collect();
2392
2393 log::debug!("Literal '{}' found in {} files", literal, file_ids.len());
2394
2395 candidate_files.extend(file_ids);
2398 }
2399
2400 let final_candidates = candidate_files;
2401 log::debug!(
2402 "After union: searching {} files that contain any literal",
2403 final_candidates.len()
2404 );
2405
2406 for &file_id in &final_candidates {
2408 let file_path = trigram_index
2409 .get_file(file_id)
2410 .context("Invalid file_id from trigram search")?;
2411 let content = content_reader.get_file_content(file_id)?;
2412
2413 self.find_regex_matches_in_file(®ex, file_path, content, &mut results)?;
2414 }
2415 }
2416 }
2417
2418 log::info!(
2419 "Regex search found {} matches for pattern '{}'",
2420 results.len(),
2421 pattern
2422 );
2423 Ok(results)
2424 }
2425
2426 fn find_regex_matches_in_file(
2428 &self,
2429 regex: &Regex,
2430 file_path: &std::path::Path,
2431 content: &str,
2432 results: &mut Vec<SearchResult>,
2433 ) -> Result<()> {
2434 let file_path_str = file_path.to_string_lossy().to_string();
2435
2436 let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
2438 let lang = Language::from_extension(ext);
2439
2440 for (line_idx, line) in content.lines().enumerate() {
2442 if regex.is_match(line) {
2443 let line_no = line_idx + 1;
2444
2445 results.push(SearchResult {
2452 path: file_path_str.clone(),
2453 lang,
2454 kind: SymbolKind::Unknown("regex_match".to_string()),
2455 symbol: None, span: Span {
2457 start_line: line_no,
2458 end_line: line_no,
2459 },
2460 preview: line.to_string(),
2461 dependencies: None,
2462 });
2463 }
2464 }
2465
2466 Ok(())
2467 }
2468
2469 fn find_file_id(content_reader: &ContentReader, target_path: &str) -> Option<u32> {
2470 result::find_file_id(content_reader, target_path)
2471 }
2472
2473 fn rebuild_trigram_index(content_reader: &ContentReader) -> Result<TrigramIndex> {
2474 result::rebuild_trigram_index(content_reader)
2475 }
2476
2477 fn normalize_glob_pattern(pattern: &str) -> String {
2478 result::normalize_glob_pattern(pattern)
2479 }
2480
2481 fn has_word_boundary_match(line: &str, pattern: &str) -> bool {
2482 filter::has_word_boundary_match(line, pattern)
2483 }
2484
2485 pub fn get_index_status(&self) -> Result<(IndexStatus, bool, Option<IndexWarning>)> {
2490 let root = self.cache.workspace_root();
2491
2492 if crate::git::is_git_repo(&root)
2494 && let Ok(current_branch) = crate::git::get_current_branch(&root)
2495 {
2496 if !self.cache.branch_exists(¤t_branch).unwrap_or(false) {
2498 let warning = IndexWarning {
2499 reason: format!("Branch '{}' has not been indexed", current_branch),
2500 action_required: "rfx index".to_string(),
2501 files_modified: None,
2502 details: Some(IndexWarningDetails {
2503 current_branch: Some(current_branch),
2504 indexed_branch: None,
2505 current_commit: None,
2506 indexed_commit: None,
2507 }),
2508 };
2509 return Ok((IndexStatus::Stale, false, Some(warning)));
2510 }
2511
2512 if let (Ok(current_commit), Ok(branch_info)) = (
2514 crate::git::get_current_commit(&root),
2515 self.cache.get_branch_info(¤t_branch),
2516 ) {
2517 if branch_info.commit_sha != current_commit {
2518 let warning = IndexWarning {
2519 reason: format!(
2520 "Commit changed from {} to {}",
2521 &branch_info.commit_sha[..7],
2522 ¤t_commit[..7]
2523 ),
2524 action_required: "rfx index".to_string(),
2525 files_modified: None,
2526 details: Some(IndexWarningDetails {
2527 current_branch: Some(current_branch.clone()),
2528 indexed_branch: Some(current_branch.clone()),
2529 current_commit: Some(current_commit.clone()),
2530 indexed_commit: Some(branch_info.commit_sha.clone()),
2531 }),
2532 };
2533 return Ok((IndexStatus::Stale, false, Some(warning)));
2534 }
2535
2536 if let Ok(branch_files) = self.cache.get_branch_files(¤t_branch) {
2538 let mut checked = 0;
2539 let mut changed = 0;
2540 const SAMPLE_SIZE: usize = 10;
2541
2542 for (path, _indexed_hash) in branch_files.iter().take(SAMPLE_SIZE) {
2543 checked += 1;
2544 let file_path = std::path::Path::new(path);
2545
2546 if let Ok(metadata) = std::fs::metadata(file_path)
2547 && let Ok(modified) = metadata.modified()
2548 {
2549 let indexed_time = branch_info.last_indexed;
2550 let file_time = modified
2551 .duration_since(std::time::UNIX_EPOCH)
2552 .unwrap_or_default()
2553 .as_secs() as i64;
2554
2555 if file_time > indexed_time {
2556 changed += 1;
2559 }
2560 }
2561 }
2562
2563 if changed > 0 {
2564 let warning = IndexWarning {
2565 reason: format!("{} of {} sampled files modified", changed, checked),
2566 action_required: "rfx index".to_string(),
2567 files_modified: Some(changed as u32),
2568 details: Some(IndexWarningDetails {
2569 current_branch: Some(current_branch.clone()),
2570 indexed_branch: Some(branch_info.branch.clone()),
2571 current_commit: Some(current_commit.clone()),
2572 indexed_commit: Some(branch_info.commit_sha.clone()),
2573 }),
2574 };
2575 return Ok((IndexStatus::Stale, false, Some(warning)));
2576 }
2577 }
2578
2579 return Ok((IndexStatus::Fresh, true, None));
2581 }
2582 }
2583
2584 Ok((IndexStatus::Fresh, true, None))
2586 }
2587
2588 fn check_index_freshness(&self, filter: &QueryFilter) -> Result<()> {
2595 let root = self.cache.workspace_root();
2596
2597 if crate::git::is_git_repo(&root) {
2599 if !crate::git::is_git_available() {
2600 static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
2601 if !filter.suppress_output {
2602 WARNED.get_or_init(|| {
2603 output::warn("⚠️ git binary not found in PATH; index freshness checks disabled for this session.");
2604 });
2605 }
2606 return Ok(());
2607 }
2608 if let Ok(current_branch) = crate::git::get_current_branch(&root) {
2609 if !self.cache.branch_exists(¤t_branch).unwrap_or(false) {
2611 if !filter.suppress_output {
2612 output::warn(&format!(
2613 "⚠️ WARNING: Index not found for branch '{}'. Run 'rfx index' to index this branch.",
2614 current_branch
2615 ));
2616 }
2617 return Ok(());
2618 }
2619
2620 if let (Ok(current_commit), Ok(branch_info)) = (
2622 crate::git::get_current_commit(&root),
2623 self.cache.get_branch_info(¤t_branch),
2624 ) {
2625 if branch_info.commit_sha != current_commit {
2626 if !filter.suppress_output {
2627 output::warn(&format!(
2628 "⚠️ WARNING: Index may be stale (commit changed: {} → {}). Consider running 'rfx index'.",
2629 &branch_info.commit_sha[..7],
2630 ¤t_commit[..7]
2631 ));
2632 }
2633 return Ok(());
2634 }
2635
2636 if let Ok(branch_files) = self.cache.get_branch_files(¤t_branch) {
2639 let mut checked = 0;
2640 let mut changed = 0;
2641 const SAMPLE_SIZE: usize = 10;
2642
2643 for (path, _indexed_hash) in branch_files.iter().take(SAMPLE_SIZE) {
2644 checked += 1;
2645 let file_path = std::path::Path::new(path);
2646
2647 if let Ok(metadata) = std::fs::metadata(file_path)
2649 && let Ok(modified) = metadata.modified()
2650 {
2651 let indexed_time = branch_info.last_indexed;
2652 let file_time = modified
2653 .duration_since(std::time::UNIX_EPOCH)
2654 .unwrap_or_default()
2655 .as_secs()
2656 as i64;
2657
2658 if file_time > indexed_time {
2660 changed += 1;
2665 }
2666 }
2667 }
2668
2669 if changed > 0 && !filter.suppress_output {
2670 output::warn(&format!(
2671 "⚠️ WARNING: {} of {} sampled files changed since indexing. Consider running 'rfx index'.",
2672 changed, checked
2673 ));
2674 }
2675 }
2676 }
2677 }
2678 }
2679
2680 Ok(())
2681 }
2682}
2683
2684#[allow(clippy::too_many_arguments)]
2689pub fn generate_ai_instruction(
2690 result_count: usize,
2691 total_count: usize,
2692 has_more: bool,
2693 symbols_mode: bool,
2694 paths_only: bool,
2695 use_ast: bool,
2696 use_regex: bool,
2697 language_filter: bool,
2698 glob_filter: bool,
2699 exact_mode: bool,
2700) -> Option<String> {
2701 if result_count == 0 {
2703 return Some(
2704 "No results found. Consider these alternatives: 1) Check pattern spelling, 2) Remove --kind or --lang filters to broaden search, 3) Try partial match or related term, 4) Use search_regex tool for pattern matching with special characters or complex patterns."
2705 .to_string()
2706 );
2707 }
2708
2709 if total_count >= 500 {
2711 return Some(format!(
2712 "Query too broad: {} results found. STOP. Do not list results. Refine search automatically by adding filters: kind parameter (Function/Struct/Class), lang parameter (rust/python/etc), or glob parameter (['src/**/*.rs']). Call search_code again with appropriate filters.",
2713 total_count
2714 ));
2715 }
2716
2717 if has_more {
2725 return Some(format!(
2726 "Showing {} of {} results — {} more available. This is a partial answer. To finish a find-all task, call again with offset={} (raise limit up to 500 to get the rest in one call), or use mode=\"count\" first if you only need the total.",
2727 result_count,
2728 total_count,
2729 total_count.saturating_sub(result_count),
2730 result_count
2731 ));
2732 }
2733
2734 if result_count == 1 && symbols_mode {
2736 return Some(
2737 "Found 1 precise result. Respond concisely: '[symbol] at [path]:[line]'.".to_string(),
2738 );
2739 }
2740
2741 if (2..=10).contains(&result_count) && symbols_mode {
2743 return Some(format!(
2744 "Found {} precise results (definitions only, not usages). List locations concisely: '[symbol] at [path]:[line]' for each result.",
2745 result_count
2746 ));
2747 }
2748
2749 if (101..500).contains(&total_count) {
2751 return Some(format!(
2752 "Found {} results - this is broad. Suggest refining search with: kind parameter (Function/Struct/Class/etc), lang parameter (rust/python/etc), or glob parameter to narrow file scope.",
2753 total_count
2754 ));
2755 }
2756
2757 if result_count >= 100 && !symbols_mode {
2759 return Some(format!(
2760 "Found {} results in full-text search mode (includes definitions AND all usages). Consider using symbols=true parameter to filter to definitions only. This typically reduces results by 80-90%.",
2761 result_count
2762 ));
2763 }
2764
2765 if paths_only {
2767 return Some(format!(
2768 "Found {} unique files (paths-only mode - no code content included). Next step: Use Read tool on specific files that look relevant based on their paths.",
2769 result_count
2770 ));
2771 }
2772
2773 if use_ast {
2775 return Some(format!(
2776 "Found {} results using AST pattern matching. These are structure-based matches using Tree-sitter patterns, not text search.",
2777 result_count
2778 ));
2779 }
2780
2781 if use_regex && result_count >= 100 {
2783 return Some(format!(
2784 "Found {} results using regex pattern matching. Regex matches are expansive. Consider using exact text search or symbols mode for more precise results.",
2785 result_count
2786 ));
2787 }
2788
2789 if language_filter && result_count <= 5 {
2791 return Some(format!(
2792 "Found {} results with language filter active. Results are limited to this language only. Remove lang parameter if you want to search all languages.",
2793 result_count
2794 ));
2795 }
2796
2797 if glob_filter && result_count <= 10 {
2799 return Some(format!(
2800 "Found {} results with glob filter active. Results are limited to matching paths. Remove glob parameter to search entire codebase.",
2801 result_count
2802 ));
2803 }
2804
2805 if exact_mode && result_count <= 5 {
2807 return Some(format!(
2808 "Found {} results in exact match mode. Only exact symbol name matches are included. Remove exact parameter to allow substring matching.",
2809 result_count
2810 ));
2811 }
2812
2813 None
2815}
2816
2817#[cfg(test)]
2818mod tests {
2819 use super::*;
2820 use crate::indexer::Indexer;
2821 use crate::models::IndexConfig;
2822 use std::fs;
2823 use tempfile::TempDir;
2824
2825 #[test]
2828 fn test_query_engine_creation() {
2829 let temp = TempDir::new().unwrap();
2830 let cache = CacheManager::new(temp.path());
2831 let engine = QueryEngine::new(cache);
2832
2833 assert!(engine.cache.path().ends_with(".reflex"));
2834 }
2835
2836 #[test]
2837 fn test_filter_modes() {
2838 let filter_fulltext = QueryFilter::default();
2840 assert!(!filter_fulltext.symbols_mode);
2841
2842 let filter_symbols = QueryFilter {
2843 symbols_mode: true,
2844 ..Default::default()
2845 };
2846 assert!(filter_symbols.symbols_mode);
2847
2848 let filter_with_kind = QueryFilter {
2850 kind: Some(SymbolKind::Function),
2851 symbols_mode: true,
2852 ..Default::default()
2853 };
2854 assert!(filter_with_kind.symbols_mode);
2855 }
2856
2857 #[test]
2860 fn test_fulltext_search() {
2861 let temp = TempDir::new().unwrap();
2862 let project = temp.path().join("project");
2863 fs::create_dir(&project).unwrap();
2864
2865 fs::write(
2867 project.join("main.rs"),
2868 "fn main() {\n println!(\"hello\");\n}",
2869 )
2870 .unwrap();
2871 fs::write(project.join("lib.rs"), "pub fn hello() {}").unwrap();
2872
2873 let cache = CacheManager::new(&project);
2875 let indexer = Indexer::new(cache, IndexConfig::default());
2876 indexer.index(&project, false).unwrap();
2877
2878 let cache = CacheManager::new(&project);
2880 let engine = QueryEngine::new(cache);
2881 let filter = QueryFilter::default(); let results = engine.search("hello", filter).unwrap();
2883
2884 assert!(results.len() >= 2);
2886 assert!(results.iter().any(|r| r.path.contains("main.rs")));
2887 assert!(results.iter().any(|r| r.path.contains("lib.rs")));
2888 }
2889
2890 #[test]
2891 fn test_symbol_search() {
2892 let temp = TempDir::new().unwrap();
2893 let project = temp.path().join("project");
2894 fs::create_dir(&project).unwrap();
2895
2896 fs::write(
2898 project.join("main.rs"),
2899 "fn greet() {}\nfn main() {\n greet();\n}",
2900 )
2901 .unwrap();
2902
2903 let cache = CacheManager::new(&project);
2905 let indexer = Indexer::new(cache, IndexConfig::default());
2906 indexer.index(&project, false).unwrap();
2907
2908 let cache = CacheManager::new(&project);
2909
2910 let engine = QueryEngine::new(cache);
2912 let filter = QueryFilter {
2913 symbols_mode: true,
2914 ..Default::default()
2915 };
2916 let results = engine.search("greet", filter).unwrap();
2917
2918 assert!(!results.is_empty());
2920 assert!(results.iter().any(|r| r.kind == SymbolKind::Function));
2921 }
2922
2923 #[test]
2924 fn test_regex_search() {
2925 let temp = TempDir::new().unwrap();
2926 let project = temp.path().join("project");
2927 fs::create_dir(&project).unwrap();
2928
2929 fs::write(
2930 project.join("main.rs"),
2931 "fn test1() {}\nfn test2() {}\nfn other() {}",
2932 )
2933 .unwrap();
2934
2935 let cache = CacheManager::new(&project);
2936 let indexer = Indexer::new(cache, IndexConfig::default());
2937 indexer.index(&project, false).unwrap();
2938
2939 let cache = CacheManager::new(&project);
2940
2941 let engine = QueryEngine::new(cache);
2942 let filter = QueryFilter {
2943 use_regex: true,
2944 ..Default::default()
2945 };
2946 let results = engine.search(r"fn test\d", filter).unwrap();
2947
2948 assert_eq!(results.len(), 2);
2950 assert!(results.iter().all(|r| r.preview.contains("test")));
2951 }
2952
2953 #[test]
2956 fn test_language_filter() {
2957 let temp = TempDir::new().unwrap();
2958 let project = temp.path().join("project");
2959 fs::create_dir(&project).unwrap();
2960
2961 fs::write(project.join("main.rs"), "fn main() {}").unwrap();
2962 fs::write(project.join("main.js"), "function main() {}").unwrap();
2963
2964 let cache = CacheManager::new(&project);
2965 let indexer = Indexer::new(cache, IndexConfig::default());
2966 indexer.index(&project, false).unwrap();
2967
2968 let cache = CacheManager::new(&project);
2969
2970 let engine = QueryEngine::new(cache);
2971
2972 let filter = QueryFilter {
2974 language: Some(Language::Rust),
2975 ..Default::default()
2976 };
2977 let results = engine.search("main", filter).unwrap();
2978
2979 assert!(results.iter().all(|r| r.lang == Language::Rust));
2980 assert!(results.iter().all(|r| r.path.ends_with(".rs")));
2981 }
2982
2983 #[test]
2984 fn test_kind_filter() {
2985 let temp = TempDir::new().unwrap();
2986 let project = temp.path().join("project");
2987 fs::create_dir(&project).unwrap();
2988
2989 fs::write(
2990 project.join("main.rs"),
2991 "struct Point {}\nfn main() {}\nimpl Point { fn new() {} }",
2992 )
2993 .unwrap();
2994
2995 let cache = CacheManager::new(&project);
2996 let indexer = Indexer::new(cache, IndexConfig::default());
2997 indexer.index(&project, false).unwrap();
2998
2999 let cache = CacheManager::new(&project);
3000
3001 let engine = QueryEngine::new(cache);
3002
3003 let filter = QueryFilter {
3005 symbols_mode: true,
3006 kind: Some(SymbolKind::Function),
3007 use_contains: true, ..Default::default()
3009 };
3010 let results = engine.search("mai", filter).unwrap();
3012
3013 assert!(!results.is_empty(), "Should find at least one result");
3015 assert!(
3016 results.iter().any(|r| r.symbol.as_deref() == Some("main")),
3017 "Should find 'main' function"
3018 );
3019 }
3020
3021 #[test]
3022 fn test_file_pattern_filter() {
3023 let temp = TempDir::new().unwrap();
3024 let project = temp.path().join("project");
3025 fs::create_dir_all(project.join("src")).unwrap();
3026 fs::create_dir_all(project.join("tests")).unwrap();
3027
3028 fs::write(project.join("src/lib.rs"), "fn foo() {}").unwrap();
3029 fs::write(project.join("tests/test.rs"), "fn foo() {}").unwrap();
3030
3031 let cache = CacheManager::new(&project);
3032 let indexer = Indexer::new(cache, IndexConfig::default());
3033 indexer.index(&project, false).unwrap();
3034
3035 let cache = CacheManager::new(&project);
3036
3037 let engine = QueryEngine::new(cache);
3038
3039 let filter = QueryFilter {
3041 file_pattern: Some("src/".to_string()),
3042 ..Default::default()
3043 };
3044 let results = engine.search("foo", filter).unwrap();
3045
3046 assert!(results.iter().all(|r| r.path.contains("src/")));
3047 assert!(!results.iter().any(|r| r.path.contains("tests/")));
3048 }
3049
3050 #[test]
3051 fn test_limit_filter() {
3052 let temp = TempDir::new().unwrap();
3053 let project = temp.path().join("project");
3054 fs::create_dir(&project).unwrap();
3055
3056 let content = (0..20)
3058 .map(|i| format!("fn test{}() {{}}", i))
3059 .collect::<Vec<_>>()
3060 .join("\n");
3061 fs::write(project.join("main.rs"), content).unwrap();
3062
3063 let cache = CacheManager::new(&project);
3064 let indexer = Indexer::new(cache, IndexConfig::default());
3065 indexer.index(&project, false).unwrap();
3066
3067 let cache = CacheManager::new(&project);
3068
3069 let engine = QueryEngine::new(cache);
3070
3071 let filter = QueryFilter {
3073 limit: Some(5),
3074 use_contains: true, ..Default::default()
3076 };
3077 let results = engine.search("test", filter).unwrap();
3078
3079 assert_eq!(results.len(), 5);
3080 }
3081
3082 #[test]
3083 fn test_exact_match_filter() {
3084 let temp = TempDir::new().unwrap();
3085 let project = temp.path().join("project");
3086 fs::create_dir(&project).unwrap();
3087
3088 fs::write(
3089 project.join("main.rs"),
3090 "fn test() {}\nfn test_helper() {}\nfn other_test() {}",
3091 )
3092 .unwrap();
3093
3094 let cache = CacheManager::new(&project);
3095 let indexer = Indexer::new(cache, IndexConfig::default());
3096 indexer.index(&project, false).unwrap();
3097
3098 let cache = CacheManager::new(&project);
3099
3100 let engine = QueryEngine::new(cache);
3101
3102 let filter = QueryFilter {
3104 symbols_mode: true,
3105 exact: true,
3106 ..Default::default()
3107 };
3108 let results = engine.search("test", filter).unwrap();
3109
3110 assert_eq!(results.len(), 1);
3112 assert_eq!(results[0].symbol.as_deref(), Some("test"));
3113 }
3114
3115 #[test]
3118 fn test_expand_mode() {
3119 let temp = TempDir::new().unwrap();
3120 let project = temp.path().join("project");
3121 fs::create_dir(&project).unwrap();
3122
3123 fs::write(
3124 project.join("main.rs"),
3125 "fn greet() {\n println!(\"Hello\");\n println!(\"World\");\n}",
3126 )
3127 .unwrap();
3128
3129 let cache = CacheManager::new(&project);
3130 let indexer = Indexer::new(cache, IndexConfig::default());
3131 indexer.index(&project, false).unwrap();
3132
3133 let cache = CacheManager::new(&project);
3134
3135 let engine = QueryEngine::new(cache);
3136
3137 let filter = QueryFilter {
3139 symbols_mode: true,
3140 expand: true,
3141 ..Default::default()
3142 };
3143 let results = engine.search("greet", filter).unwrap();
3144
3145 assert!(!results.is_empty());
3147 let result = &results[0];
3148 assert!(result.preview.contains("println"));
3149 }
3150
3151 #[test]
3154 fn test_search_empty_index() {
3155 let temp = TempDir::new().unwrap();
3156 let project = temp.path().join("project");
3157 fs::create_dir(&project).unwrap();
3158
3159 let cache = CacheManager::new(&project);
3160 let indexer = Indexer::new(cache, IndexConfig::default());
3161 indexer.index(&project, false).unwrap();
3162
3163 let cache = CacheManager::new(&project);
3164
3165 let engine = QueryEngine::new(cache);
3166 let filter = QueryFilter::default();
3167 let results = engine.search("nonexistent", filter).unwrap();
3168
3169 assert_eq!(results.len(), 0);
3170 }
3171
3172 #[test]
3173 fn test_search_no_index() {
3174 let temp = TempDir::new().unwrap();
3175 let project = temp.path().join("project");
3176 fs::create_dir(&project).unwrap();
3177
3178 let cache = CacheManager::new(&project);
3179 let engine = QueryEngine::new(cache);
3180 let filter = QueryFilter::default();
3181
3182 assert!(engine.search("test", filter).is_err());
3184 }
3185
3186 #[test]
3187 fn test_search_special_characters() {
3188 let temp = TempDir::new().unwrap();
3189 let project = temp.path().join("project");
3190 fs::create_dir(&project).unwrap();
3191
3192 fs::write(project.join("main.rs"), "let x = 42;\nlet y = x + 1;").unwrap();
3193
3194 let cache = CacheManager::new(&project);
3195 let indexer = Indexer::new(cache, IndexConfig::default());
3196 indexer.index(&project, false).unwrap();
3197
3198 let cache = CacheManager::new(&project);
3199
3200 let engine = QueryEngine::new(cache);
3201 let filter = QueryFilter::default();
3202
3203 let results = engine.search("x + ", filter).unwrap();
3205 assert!(!results.is_empty());
3206 }
3207
3208 #[test]
3209 fn test_search_unicode() {
3210 let temp = TempDir::new().unwrap();
3211 let project = temp.path().join("project");
3212 fs::create_dir(&project).unwrap();
3213
3214 fs::write(project.join("main.rs"), "// 你好世界\nfn main() {}").unwrap();
3215
3216 let cache = CacheManager::new(&project);
3217 let indexer = Indexer::new(cache, IndexConfig::default());
3218 indexer.index(&project, false).unwrap();
3219
3220 let cache = CacheManager::new(&project);
3221
3222 let engine = QueryEngine::new(cache);
3223 let filter = QueryFilter {
3224 use_contains: true, force: true, ..Default::default()
3227 };
3228
3229 let results = engine.search("你好", filter).unwrap();
3231 assert!(!results.is_empty());
3232 }
3233
3234 #[test]
3235 fn test_case_sensitive_search() {
3236 let temp = TempDir::new().unwrap();
3237 let project = temp.path().join("project");
3238 fs::create_dir(&project).unwrap();
3239
3240 fs::write(project.join("main.rs"), "fn Test() {}\nfn test() {}").unwrap();
3241
3242 let cache = CacheManager::new(&project);
3243 let indexer = Indexer::new(cache, IndexConfig::default());
3244 indexer.index(&project, false).unwrap();
3245
3246 let cache = CacheManager::new(&project);
3247
3248 let engine = QueryEngine::new(cache);
3249 let filter = QueryFilter::default();
3250
3251 let results = engine.search("Test", filter).unwrap();
3253 assert!(results.iter().any(|r| r.preview.contains("Test()")));
3254 }
3255
3256 #[test]
3259 fn test_results_sorted_deterministically() {
3260 let temp = TempDir::new().unwrap();
3261 let project = temp.path().join("project");
3262 fs::create_dir(&project).unwrap();
3263
3264 fs::write(project.join("a.rs"), "fn test() {}").unwrap();
3265 fs::write(project.join("z.rs"), "fn test() {}").unwrap();
3266 fs::write(project.join("m.rs"), "fn test() {}\nfn test2() {}").unwrap();
3267
3268 let cache = CacheManager::new(&project);
3269 let indexer = Indexer::new(cache, IndexConfig::default());
3270 indexer.index(&project, false).unwrap();
3271
3272 let cache = CacheManager::new(&project);
3273
3274 let engine = QueryEngine::new(cache);
3275 let filter = QueryFilter::default();
3276
3277 let results1 = engine.search("test", filter.clone()).unwrap();
3279 let results2 = engine.search("test", filter.clone()).unwrap();
3280 let results3 = engine.search("test", filter).unwrap();
3281
3282 assert_eq!(results1.len(), results2.len());
3284 assert_eq!(results1.len(), results3.len());
3285
3286 for i in 0..results1.len() {
3287 assert_eq!(results1[i].path, results2[i].path);
3288 assert_eq!(results1[i].path, results3[i].path);
3289 assert_eq!(results1[i].span.start_line, results2[i].span.start_line);
3290 assert_eq!(results1[i].span.start_line, results3[i].span.start_line);
3291 }
3292
3293 for i in 0..results1.len().saturating_sub(1) {
3295 let curr = &results1[i];
3296 let next = &results1[i + 1];
3297 assert!(
3298 curr.path < next.path
3299 || (curr.path == next.path && curr.span.start_line <= next.span.start_line)
3300 );
3301 }
3302 }
3303
3304 #[test]
3307 fn test_multiple_filters_combined() {
3308 let temp = TempDir::new().unwrap();
3309 let project = temp.path().join("project");
3310 fs::create_dir_all(project.join("src")).unwrap();
3311
3312 fs::write(project.join("src/main.rs"), "fn test() {}\nstruct Test {}").unwrap();
3313 fs::write(project.join("src/lib.rs"), "fn test() {}").unwrap();
3314 fs::write(project.join("test.js"), "function test() {}").unwrap();
3315
3316 let cache = CacheManager::new(&project);
3317 let indexer = Indexer::new(cache, IndexConfig::default());
3318 indexer.index(&project, false).unwrap();
3319
3320 let cache = CacheManager::new(&project);
3321
3322 let engine = QueryEngine::new(cache);
3323
3324 let filter = QueryFilter {
3326 language: Some(Language::Rust),
3327 kind: Some(SymbolKind::Function),
3328 file_pattern: Some("src/main".to_string()),
3329 symbols_mode: true,
3330 ..Default::default()
3331 };
3332 let results = engine.search("test", filter).unwrap();
3333
3334 assert_eq!(results.len(), 1);
3336 assert!(results[0].path.contains("src/main.rs"));
3337 assert_eq!(results[0].kind, SymbolKind::Function);
3338 }
3339
3340 #[test]
3343 fn test_find_symbol_helper() {
3344 let temp = TempDir::new().unwrap();
3345 let project = temp.path().join("project");
3346 fs::create_dir(&project).unwrap();
3347
3348 fs::write(project.join("main.rs"), "fn greet() {}").unwrap();
3349
3350 let cache = CacheManager::new(&project);
3351 let indexer = Indexer::new(cache, IndexConfig::default());
3352 indexer.index(&project, false).unwrap();
3353
3354 let cache = CacheManager::new(&project);
3355
3356 let engine = QueryEngine::new(cache);
3357 let results = engine.find_symbol("greet").unwrap();
3358
3359 assert!(!results.is_empty());
3360 assert_eq!(results[0].kind, SymbolKind::Function);
3361 }
3362
3363 #[test]
3364 fn test_list_by_kind_helper() {
3365 let temp = TempDir::new().unwrap();
3366 let project = temp.path().join("project");
3367 fs::create_dir(&project).unwrap();
3368
3369 fs::write(
3370 project.join("main.rs"),
3371 "struct Point {}\nfn test() {}\nstruct Line {}",
3372 )
3373 .unwrap();
3374
3375 let cache = CacheManager::new(&project);
3376 let indexer = Indexer::new(cache, IndexConfig::default());
3377 indexer.index(&project, false).unwrap();
3378
3379 let cache = CacheManager::new(&project);
3380
3381 let engine = QueryEngine::new(cache);
3382
3383 let filter = QueryFilter {
3385 kind: Some(SymbolKind::Struct),
3386 symbols_mode: true,
3387 use_contains: true, ..Default::default()
3389 };
3390 let results = engine.search("oin", filter).unwrap();
3391
3392 assert!(!results.is_empty(), "Should find at least Point struct");
3394 assert!(results.iter().all(|r| r.kind == SymbolKind::Struct));
3395 assert!(results.iter().any(|r| r.symbol.as_deref() == Some("Point")));
3396 }
3397
3398 #[test]
3401 fn test_search_with_metadata() {
3402 let temp = TempDir::new().unwrap();
3403 let project = temp.path().join("project");
3404 fs::create_dir(&project).unwrap();
3405
3406 fs::write(project.join("main.rs"), "fn test() {}").unwrap();
3407
3408 let cache = CacheManager::new(&project);
3409 let indexer = Indexer::new(cache, IndexConfig::default());
3410 indexer.index(&project, false).unwrap();
3411
3412 let cache = CacheManager::new(&project);
3413
3414 let engine = QueryEngine::new(cache);
3415 let filter = QueryFilter::default();
3416 let response = engine.search_with_metadata("test", filter).unwrap();
3417
3418 assert!(!response.results.is_empty());
3420 }
3422
3423 #[test]
3426 fn test_search_across_languages() {
3427 let temp = TempDir::new().unwrap();
3428 let project = temp.path().join("project");
3429 fs::create_dir(&project).unwrap();
3430
3431 fs::write(project.join("main.rs"), "fn greet() {}").unwrap();
3432 fs::write(project.join("main.ts"), "function greet() {}").unwrap();
3433 fs::write(project.join("main.py"), "def greet(): pass").unwrap();
3434
3435 let cache = CacheManager::new(&project);
3436 let indexer = Indexer::new(cache, IndexConfig::default());
3437 indexer.index(&project, false).unwrap();
3438
3439 let cache = CacheManager::new(&project);
3440
3441 let engine = QueryEngine::new(cache);
3442 let filter = QueryFilter::default();
3443 let results = engine.search("greet", filter).unwrap();
3444
3445 assert!(results.len() >= 3);
3447 assert!(results.iter().any(|r| r.lang == Language::Rust));
3448 assert!(results.iter().any(|r| r.lang == Language::TypeScript));
3449 assert!(results.iter().any(|r| r.lang == Language::Python));
3450 }
3451}