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 return Err(crate::errors::ReflexError::IndexNotFound.into());
274 }
275
276 if let Err(e) = self.cache.validate() {
278 return Err(crate::errors::ReflexError::CacheCorrupted(e.to_string()).into());
280 }
281
282 let (status, can_trust_results, warning) = self.get_index_status()?;
284
285 let (results, total) = self.search_internal(pattern, filter.clone())?;
287
288 use crate::models::PaginationInfo;
290 let pagination = PaginationInfo {
291 total,
292 count: results.len(),
293 offset: filter.offset.unwrap_or(0),
294 limit: filter.limit,
295 has_more: total > filter.offset.unwrap_or(0) + results.len(),
296 };
297
298 let grouped_results = self.group_and_load_dependencies(
301 results,
302 filter.include_dependencies,
303 filter.context_lines,
304 )?;
305
306 Ok(QueryResponse {
307 ai_instruction: None, status,
309 can_trust_results,
310 warning,
311 pagination,
312 results: grouped_results,
313 })
314 }
315
316 pub fn search(&self, pattern: &str, filter: QueryFilter) -> Result<Vec<SearchResult>> {
321 log::info!(
322 "Executing query: pattern='{}', filter={:?}",
323 pattern,
324 filter
325 );
326
327 if !self.cache.exists() {
329 return Err(crate::errors::ReflexError::IndexNotFound.into());
330 }
331
332 if let Err(e) = self.cache.validate() {
334 return Err(crate::errors::ReflexError::CacheCorrupted(e.to_string()).into());
336 }
337
338 self.check_index_freshness(&filter)?;
340
341 let (mut results, _total_count) = self.search_internal(pattern, filter.clone())?;
343
344 self.load_dependencies(&mut results, filter.include_dependencies)?;
346
347 Ok(results)
348 }
349
350 fn search_internal(
353 &self,
354 pattern: &str,
355 filter: QueryFilter,
356 ) -> Result<(Vec<SearchResult>, usize)> {
357 use std::time::{Duration, Instant};
358
359 let start_time = Instant::now();
361 let timeout = if filter.timeout_secs > 0 {
362 Some(Duration::from_secs(filter.timeout_secs))
363 } else {
364 None
365 };
366
367 let is_keyword_query = if filter.symbols_mode || filter.kind.is_some() {
381 pattern.is_empty() || ParserFactory::get_all_keywords().contains(&pattern)
382 } else {
383 false
384 };
385
386 let mut filter = filter.clone(); if is_keyword_query
391 && filter.kind.is_none()
392 && let Some(inferred_kind) = Self::keyword_to_kind(pattern)
393 {
394 log::info!(
395 "Keyword '{}' mapped to kind {:?} (auto-inferred)",
396 pattern,
397 inferred_kind
398 );
399 filter.kind = Some(inferred_kind);
400 }
401
402 if !filter.force && !filter.use_regex && !is_keyword_query {
414 let stats = self.cache.stats()?;
415 let total_files = stats.total_files;
416 let pattern_len = pattern.chars().count();
417
418 let large_index_threshold = filter.test_large_index_threshold.unwrap_or(20_000);
423 let short_pattern_threshold = filter.test_short_pattern_threshold.unwrap_or(4);
424
425 if total_files > large_index_threshold && pattern_len < short_pattern_threshold {
426 anyhow::bail!(
427 "Query too broad - would be expensive to execute on this large index\n\
428 \n\
429 This index contains {} files, and pattern '{}' ({} characters) is too short for efficient searching.\n\
430 On large codebases, short patterns can take 10-30+ seconds to complete.\n\
431 \n\
432 This query could:\n\
433 • Hang for an extended period before returning results\n\
434 • Return thousands of results\n\
435 • Flood LLM context windows with excessive data\n\
436 • Fail entirely\n\
437 \n\
438 Suggestions to narrow the query:\n\
439 • Use a longer, more specific pattern (4+ characters recommended for large indexes)\n\
440 • Add a language filter: --lang <language>\n\
441 • Add a file filter: --glob <pattern> or --file <path>\n\
442 • Use --force to bypass this check if you really need all results\n\
443 \n\
444 To force execution anyway:\n\
445 rfx query \"{}\" --force",
446 total_files,
447 pattern,
448 pattern_len,
449 pattern
450 );
451 }
452 }
453
454 let mut results = if is_keyword_query {
456 if let Some(lang) = filter.language {
459 log::info!(
460 "Keyword query detected for '{}' - scanning all {:?} files (bypassing trigram search)",
461 pattern,
462 lang
463 );
464 } else {
465 log::info!(
466 "Keyword query detected for '{}' - scanning all files (bypassing trigram search)",
467 pattern
468 );
469 }
470 self.get_all_language_files(&filter)?
471 } else if filter.use_regex {
472 self.get_regex_candidates(
474 pattern,
475 timeout.as_ref(),
476 &start_time,
477 filter.suppress_output,
478 )?
479 } else {
480 self.get_trigram_candidates(pattern, &filter)?
482 };
483
484 if !is_keyword_query && let Some(lang) = filter.language {
490 let before_count = results.len();
491 results.retain(|r| r.lang == lang);
492 log::debug!(
493 "Language filter ({:?}): reduced {} candidates to {} candidates",
494 lang,
495 before_count,
496 results.len()
497 );
498 }
499
500 if !filter.glob_patterns.is_empty() || !filter.exclude_patterns.is_empty() {
504 use globset::{Glob, GlobSetBuilder};
505
506 let include_matcher = if !filter.glob_patterns.is_empty() {
508 let mut builder = GlobSetBuilder::new();
509 for pattern in &filter.glob_patterns {
510 let normalized = Self::normalize_glob_pattern(pattern);
512 match Glob::new(&normalized) {
513 Ok(glob) => {
514 builder.add(glob);
515 }
516 Err(e) => {
517 log::warn!("Invalid glob pattern '{}': {}", pattern, e);
518 }
519 }
520 }
521 match builder.build() {
522 Ok(matcher) => Some(matcher),
523 Err(e) => {
524 log::warn!("Failed to build glob matcher: {}", e);
525 None
526 }
527 }
528 } else {
529 None
530 };
531
532 let exclude_matcher = if !filter.exclude_patterns.is_empty() {
534 let mut builder = GlobSetBuilder::new();
535 for pattern in &filter.exclude_patterns {
536 let normalized = Self::normalize_glob_pattern(pattern);
538 match Glob::new(&normalized) {
539 Ok(glob) => {
540 builder.add(glob);
541 }
542 Err(e) => {
543 log::warn!("Invalid exclude pattern '{}': {}", pattern, e);
544 }
545 }
546 }
547 match builder.build() {
548 Ok(matcher) => Some(matcher),
549 Err(e) => {
550 log::warn!("Failed to build exclude matcher: {}", e);
551 None
552 }
553 }
554 } else {
555 None
556 };
557
558 let before_count = results.len();
560 results.retain(|r| {
561 let included = if let Some(ref matcher) = include_matcher {
563 matcher.is_match(&r.path)
564 } else {
565 true };
567
568 let excluded = if let Some(ref matcher) = exclude_matcher {
570 matcher.is_match(&r.path)
571 } else {
572 false };
574
575 included && !excluded
576 });
577 log::debug!(
578 "Glob filter: reduced {} candidates to {} candidates",
579 before_count,
580 results.len()
581 );
582 }
583
584 if let Some(timeout_duration) = timeout
586 && start_time.elapsed() > timeout_duration
587 {
588 anyhow::bail!(
589 "Query timeout exceeded ({} seconds).\n\
590 \n\
591 The query took too long to complete. Try one of these approaches:\n\
592 • Use a more specific search pattern (longer patterns = faster search)\n\
593 • Add a language filter with --lang to narrow the search space\n\
594 • Add a file filter with --file to search specific directories\n\
595 • Increase the timeout with --timeout <seconds>\n\
596 \n\
597 Example: rfx query \"{}\" --lang rust --timeout 60",
598 filter.timeout_secs,
599 pattern
600 );
601 }
602
603 if !filter.force {
606 let candidate_count = results.len();
607 let pattern_len = pattern.chars().count();
608
609 let is_short_pattern = pattern_len < 3 && !filter.use_regex && !is_keyword_query;
612
613 let is_broad_ast =
616 filter.use_ast && filter.glob_patterns.is_empty() && candidate_count >= 100;
617
618 let threshold = if filter.use_ast && filter.glob_patterns.is_empty() {
625 100 } else if filter.use_ast {
627 10_000 } else if is_keyword_query {
629 20_000 } else {
631 50_000 };
633
634 let has_many_candidates = candidate_count > threshold
635 && (filter.symbols_mode || filter.kind.is_some() || filter.use_ast);
636
637 if is_short_pattern || has_many_candidates || is_broad_ast {
638 let reason = if is_short_pattern {
639 format!(
640 "Pattern '{}' is too short ({} characters). Short patterns bypass trigram optimization and require scanning many files.",
641 pattern, pattern_len
642 )
643 } else if is_broad_ast {
644 format!(
645 "AST query without --glob restriction will scan the entire codebase ({} files). AST queries are SLOW (500ms-10s+).",
646 candidate_count
647 )
648 } else if is_keyword_query {
649 format!(
650 "Keyword query '{}' matched {} files. This query scans all files of the target language, which will take significant time and produce excessive results.",
651 pattern, candidate_count
652 )
653 } else {
654 format!(
655 "Query matched {} files. Parsing this many files with --symbols or --kind will take significant time and produce excessive results.",
656 candidate_count
657 )
658 };
659
660 let suggestions = if is_short_pattern {
661 vec![
662 "• Use a longer, more specific pattern (3+ characters recommended)",
663 "• Add a language filter: --lang <language>",
664 "• Add a file path filter: --file <path> or --glob <pattern>",
665 "• Use --force to bypass this check if you really need all results",
666 ]
667 } else if is_broad_ast {
668 vec![
669 "• Add --glob to restrict AST query to specific files: --glob 'src/**/*.rs'",
670 "• Use --symbols instead (10-100x faster in 95% of cases)",
671 "• Use --force to bypass this check if you need a full codebase scan",
672 ]
673 } else if is_keyword_query {
674 vec![
675 "• Add a language filter to reduce files scanned: --lang <language>",
676 "• Add glob patterns to search specific directories: --glob 'src/**/*.rs'",
677 "• Add --kind to filter to specific symbol types: --kind function",
678 "• Use a more specific pattern instead of a keyword",
679 "• Use --force to bypass this check if you need all results",
680 ]
681 } else {
682 vec![
683 "• Add a language filter to reduce candidate set: --lang <language>",
684 "• Add glob patterns to search specific directories: --glob 'src/**/*.rs'",
685 "• Use a more specific search pattern",
686 "• Use --force to bypass this check if you need all results",
687 ]
688 };
689
690 let mut cmd_flags = String::new();
692 if filter.symbols_mode {
693 cmd_flags.push_str("--symbols ");
694 }
695 if let Some(ref lang) = filter.language {
696 cmd_flags.push_str(&format!("--lang {:?} ", lang));
697 }
698 if let Some(ref kind) = filter.kind {
699 cmd_flags.push_str(&format!("--kind {:?} ", kind));
700 }
701 if filter.use_ast {
702 cmd_flags.push_str("--ast ");
703 }
704
705 anyhow::bail!(
706 "Query too broad - would be expensive to execute\n\
707 \n\
708 {}\n\
709 \n\
710 This query could:\n\
711 • Hang for an extended period before returning results\n\
712 • Return thousands of results\n\
713 • Flood LLM context windows with excessive data\n\
714 • Fail entirely\n\
715 \n\
716 Suggestions to narrow the query:\n\
717 {}\n\
718 \n\
719 To force execution anyway:\n\
720 rfx query \"{}\" --force {}",
721 reason,
722 suggestions.join("\n "),
723 pattern,
724 cmd_flags
725 );
726 }
727 }
728
729 if filter.symbols_mode || filter.kind.is_some() || filter.use_ast {
732 results.sort_by(|a, b| {
733 a.path
734 .cmp(&b.path)
735 .then_with(|| a.span.start_line.cmp(&b.span.start_line))
736 });
737
738 let candidate_count = results.len();
740 if candidate_count > 1000 && !filter.suppress_output {
741 output::warn(&format!(
742 "Pattern '{}' matched {} files - parsing may take some time. Consider using --file, --glob, or a more specific pattern to narrow the search.",
743 pattern, candidate_count
744 ));
745 } else if candidate_count > 100 {
746 log::info!(
747 "Parsing {} candidate files for symbol extraction",
748 candidate_count
749 );
750 }
751 }
752
753 if filter.use_ast {
755 results = self.enrich_with_ast(results, pattern, filter.language)?;
757 } else if filter.symbols_mode || filter.kind.is_some() {
758 results = self.enrich_with_symbols(results, pattern, &filter)?;
760 }
761
762 if filter.symbols_mode || filter.kind.is_some() {
771 let mut seen = std::collections::HashSet::<(String, usize, Option<String>)>::new();
772 results.retain(|r| seen.insert((r.path.clone(), r.span.start_line, r.symbol.clone())));
773 }
774
775 if let Some(ref kind) = filter.kind {
778 results.retain(|r| {
779 if matches!(kind, SymbolKind::Function) {
780 matches!(r.kind, SymbolKind::Function | SymbolKind::Method)
782 } else {
783 r.kind == *kind
784 }
785 });
786 }
787
788 if let Some(ref file_pattern) = filter.file_pattern {
790 results.retain(|r| r.path.contains(file_pattern));
791 }
792
793 if filter.exact && filter.symbols_mode {
795 results.retain(|r| r.symbol.as_deref() == Some(pattern));
796 }
797
798 if filter.expand {
801 let content_path = self.cache.path().join("content.bin");
803 if let Ok(content_reader) = ContentReader::open(&content_path) {
804 for result in &mut results {
805 if result.span.start_line < result.span.end_line {
807 if let Some(file_id) = Self::find_file_id(&content_reader, &result.path) {
809 if let Ok(content) = content_reader.get_file_content(file_id) {
811 let lines: Vec<&str> = content.lines().collect();
812 let start_idx = result.span.start_line.saturating_sub(1);
813 let end_idx = result.span.end_line.min(lines.len());
814
815 if start_idx < end_idx {
816 let full_body = lines[start_idx..end_idx].join("\n");
817 result.preview = full_body;
818 }
819 }
820 }
821 }
822 }
823 }
824 }
825
826 if filter.paths_only {
828 use std::collections::HashSet;
829 let mut seen_paths = HashSet::new();
830 results.retain(|r| seen_paths.insert(r.path.clone()));
831 }
832
833 results.sort_by(|a, b| {
835 a.path
836 .cmp(&b.path)
837 .then_with(|| a.span.start_line.cmp(&b.span.start_line))
838 });
839
840 let total_count = results.len();
843
844 if let Some(offset) = filter.offset {
846 if offset < results.len() {
847 results = results.into_iter().skip(offset).collect();
848 } else {
849 results.clear();
851 }
852 }
853
854 if let Some(limit) = filter.limit {
856 results.truncate(limit);
857 }
858
859 log::info!(
860 "Query returned {} results (total before pagination: {})",
861 results.len(),
862 total_count
863 );
864
865 Ok((results, total_count))
866 }
867
868 pub fn find_symbol(&self, name: &str) -> Result<Vec<SearchResult>> {
870 let filter = QueryFilter {
871 symbols_mode: true,
872 ..Default::default()
873 };
874 self.search(name, filter)
875 }
876
877 pub fn search_ast(&self, pattern: &str, lang: Option<Language>) -> Result<Vec<SearchResult>> {
879 let filter = QueryFilter {
880 language: lang,
881 use_ast: true,
882 ..Default::default()
883 };
884
885 self.search(pattern, filter)
886 }
887
888 pub fn search_ast_all_files(
909 &self,
910 ast_pattern: &str,
911 filter: QueryFilter,
912 ) -> Result<Vec<SearchResult>> {
913 log::info!(
914 "Executing AST query on all files: pattern='{}', filter={:?}",
915 ast_pattern,
916 filter
917 );
918
919 let lang = filter.language.ok_or_else(|| anyhow::anyhow!(
921 "Language must be specified for AST pattern matching. Use --lang to specify the language.\n\
922 \n\
923 Example: rfx query \"(function_definition) @fn\" --ast --lang python"
924 ))?;
925
926 if !self.cache.exists() {
928 return Err(crate::errors::ReflexError::IndexNotFound.into());
929 }
930
931 self.check_index_freshness(&filter)?;
933
934 let content_path = self.cache.path().join("content.bin");
936 let content_reader =
937 ContentReader::open(&content_path).context("Failed to open content store")?;
938
939 use globset::{Glob, GlobSetBuilder};
941
942 let include_matcher = if !filter.glob_patterns.is_empty() {
943 let mut builder = GlobSetBuilder::new();
944 for pattern in &filter.glob_patterns {
945 let normalized = Self::normalize_glob_pattern(pattern);
947 if let Ok(glob) = Glob::new(&normalized) {
948 builder.add(glob);
949 }
950 }
951 builder.build().ok()
952 } else {
953 None
954 };
955
956 let exclude_matcher = if !filter.exclude_patterns.is_empty() {
957 let mut builder = GlobSetBuilder::new();
958 for pattern in &filter.exclude_patterns {
959 let normalized = Self::normalize_glob_pattern(pattern);
961 if let Ok(glob) = Glob::new(&normalized) {
962 builder.add(glob);
963 }
964 }
965 builder.build().ok()
966 } else {
967 None
968 };
969
970 let mut candidates: Vec<SearchResult> = Vec::new();
972
973 for file_id in 0..content_reader.file_count() {
974 let file_path = match content_reader.get_file_path(file_id as u32) {
975 Some(p) => p,
976 None => continue,
977 };
978
979 let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
981 let detected_lang = Language::from_extension(ext);
982
983 if detected_lang != lang {
985 continue;
986 }
987
988 let file_path_str = file_path.to_string_lossy().to_string();
989
990 let included = include_matcher
992 .as_ref()
993 .is_none_or(|m| m.is_match(&file_path_str));
994 let excluded = exclude_matcher
995 .as_ref()
996 .is_some_and(|m| m.is_match(&file_path_str));
997
998 if !included || excluded {
999 continue;
1000 }
1001
1002 candidates.push(SearchResult {
1004 path: file_path_str,
1005 lang: detected_lang,
1006 span: Span {
1007 start_line: 1,
1008 end_line: 1,
1009 },
1010 symbol: None,
1011 kind: SymbolKind::Unknown("ast_query".to_string()),
1012 preview: String::new(),
1013 dependencies: None,
1014 });
1015 }
1016
1017 log::info!(
1018 "AST query scanning {} files for language {:?}",
1019 candidates.len(),
1020 lang
1021 );
1022
1023 if !filter.force && filter.glob_patterns.is_empty() && candidates.len() >= 100 {
1026 anyhow::bail!(
1027 "Query too broad - would be expensive to execute\n\
1028 \n\
1029 AST query without --glob restriction will scan the ENTIRE codebase ({} files). AST queries are SLOW (500ms-10s+).\n\
1030 \n\
1031 This query could:\n\
1032 • Hang for an extended period before returning results\n\
1033 • Return thousands of results\n\
1034 • Flood LLM context windows with excessive data\n\
1035 • Fail entirely\n\
1036 \n\
1037 Suggestions to narrow the query:\n\
1038 • Add --glob to restrict AST query to specific files: --glob 'src/**/*.rs'\n\
1039 • Use --symbols instead (10-100x faster in 95% of cases)\n\
1040 • Use --force to bypass this check if you need a full codebase scan\n\
1041 \n\
1042 To force execution anyway:\n\
1043 rfx query \"{}\" --force --ast --lang {:?}",
1044 candidates.len(),
1045 ast_pattern,
1046 lang
1047 );
1048 }
1049
1050 if candidates.is_empty() {
1051 if !filter.suppress_output {
1052 output::warn(&format!(
1053 "No files found for language {:?}. Check your language filter or glob patterns.",
1054 lang
1055 ));
1056 }
1057 return Ok(Vec::new());
1058 }
1059
1060 let mut results = self.enrich_with_ast(candidates, ast_pattern, filter.language)?;
1063
1064 log::debug!("AST query found {} matches before filtering", results.len());
1065
1066 if let Some(ref kind) = filter.kind {
1070 results.retain(|r| {
1071 if matches!(kind, SymbolKind::Function) {
1072 matches!(r.kind, SymbolKind::Function | SymbolKind::Method)
1073 } else {
1074 r.kind == *kind
1075 }
1076 });
1077 }
1078
1079 if filter.expand {
1083 let content_path = self.cache.path().join("content.bin");
1084 if let Ok(content_reader) = ContentReader::open(&content_path) {
1085 for result in &mut results {
1086 if result.span.start_line < result.span.end_line
1087 && let Some(file_id) = Self::find_file_id(&content_reader, &result.path)
1088 && let Ok(content) = content_reader.get_file_content(file_id)
1089 {
1090 let lines: Vec<&str> = content.lines().collect();
1091 let start_idx = result.span.start_line.saturating_sub(1);
1092 let end_idx = result.span.end_line.min(lines.len());
1093
1094 if start_idx < end_idx {
1095 let full_body = lines[start_idx..end_idx].join("\n");
1096 result.preview = full_body;
1097 }
1098 }
1099 }
1100 }
1101 }
1102
1103 if filter.paths_only {
1105 use std::collections::HashSet;
1106 let mut seen_paths = HashSet::new();
1107 results.retain(|r| seen_paths.insert(r.path.clone()));
1108 }
1109
1110 results.sort_by(|a, b| {
1112 a.path
1113 .cmp(&b.path)
1114 .then_with(|| a.span.start_line.cmp(&b.span.start_line))
1115 });
1116
1117 if let Some(offset) = filter.offset {
1119 if offset < results.len() {
1120 results = results.into_iter().skip(offset).collect();
1121 } else {
1122 results.clear();
1123 }
1124 }
1125
1126 if let Some(limit) = filter.limit {
1128 results.truncate(limit);
1129 }
1130
1131 log::info!("AST query returned {} results", results.len());
1132
1133 self.load_dependencies(&mut results, filter.include_dependencies)?;
1135
1136 Ok(results)
1137 }
1138
1139 pub fn search_ast_with_text_filter(
1151 &self,
1152 text_pattern: &str,
1153 ast_pattern: &str,
1154 filter: QueryFilter,
1155 ) -> Result<Vec<SearchResult>> {
1156 log::info!(
1157 "Executing AST query with text filter: text='{}', ast='{}', filter={:?}",
1158 text_pattern,
1159 ast_pattern,
1160 filter
1161 );
1162
1163 if !self.cache.exists() {
1165 return Err(crate::errors::ReflexError::IndexNotFound.into());
1166 }
1167
1168 self.check_index_freshness(&filter)?;
1170
1171 use std::time::{Duration, Instant};
1173 let start_time = Instant::now();
1174 let timeout = if filter.timeout_secs > 0 {
1175 Some(Duration::from_secs(filter.timeout_secs))
1176 } else {
1177 None
1178 };
1179
1180 let candidates = if filter.use_regex {
1182 self.get_regex_candidates(
1183 text_pattern,
1184 timeout.as_ref(),
1185 &start_time,
1186 filter.suppress_output,
1187 )?
1188 } else {
1189 self.get_trigram_candidates(text_pattern, &filter)?
1190 };
1191
1192 log::debug!("Phase 1 found {} candidate locations", candidates.len());
1193
1194 let mut results = self.enrich_with_ast(candidates, ast_pattern, filter.language)?;
1196
1197 log::debug!("Phase 2 AST matching found {} results", results.len());
1198
1199 if let Some(lang) = filter.language {
1201 results.retain(|r| r.lang == lang);
1202 }
1203
1204 if let Some(ref kind) = filter.kind {
1205 results.retain(|r| {
1206 if matches!(kind, SymbolKind::Function) {
1207 matches!(r.kind, SymbolKind::Function | SymbolKind::Method)
1208 } else {
1209 r.kind == *kind
1210 }
1211 });
1212 }
1213
1214 if let Some(ref file_pattern) = filter.file_pattern {
1215 results.retain(|r| r.path.contains(file_pattern));
1216 }
1217
1218 if !filter.glob_patterns.is_empty() || !filter.exclude_patterns.is_empty() {
1220 use globset::{Glob, GlobSetBuilder};
1221
1222 let include_matcher = if !filter.glob_patterns.is_empty() {
1223 let mut builder = GlobSetBuilder::new();
1224 for pattern in &filter.glob_patterns {
1225 let normalized = Self::normalize_glob_pattern(pattern);
1227 if let Ok(glob) = Glob::new(&normalized) {
1228 builder.add(glob);
1229 }
1230 }
1231 builder.build().ok()
1232 } else {
1233 None
1234 };
1235
1236 let exclude_matcher = if !filter.exclude_patterns.is_empty() {
1237 let mut builder = GlobSetBuilder::new();
1238 for pattern in &filter.exclude_patterns {
1239 let normalized = Self::normalize_glob_pattern(pattern);
1241 if let Ok(glob) = Glob::new(&normalized) {
1242 builder.add(glob);
1243 }
1244 }
1245 builder.build().ok()
1246 } else {
1247 None
1248 };
1249
1250 results.retain(|r| {
1251 let included = include_matcher.as_ref().is_none_or(|m| m.is_match(&r.path));
1252 let excluded = exclude_matcher
1253 .as_ref()
1254 .is_some_and(|m| m.is_match(&r.path));
1255 included && !excluded
1256 });
1257 }
1258
1259 if filter.exact && filter.symbols_mode {
1260 results.retain(|r| r.symbol.as_deref() == Some(text_pattern));
1261 }
1262
1263 if filter.expand {
1265 let content_path = self.cache.path().join("content.bin");
1266 if let Ok(content_reader) = ContentReader::open(&content_path) {
1267 for result in &mut results {
1268 if result.span.start_line < result.span.end_line
1269 && let Some(file_id) = Self::find_file_id(&content_reader, &result.path)
1270 && let Ok(content) = content_reader.get_file_content(file_id)
1271 {
1272 let lines: Vec<&str> = content.lines().collect();
1273 let start_idx = result.span.start_line.saturating_sub(1);
1274 let end_idx = result.span.end_line.min(lines.len());
1275
1276 if start_idx < end_idx {
1277 let full_body = lines[start_idx..end_idx].join("\n");
1278 result.preview = full_body;
1279 }
1280 }
1281 }
1282 }
1283 }
1284
1285 results.sort_by(|a, b| {
1287 a.path
1288 .cmp(&b.path)
1289 .then_with(|| a.span.start_line.cmp(&b.span.start_line))
1290 });
1291
1292 if let Some(offset) = filter.offset {
1294 if offset < results.len() {
1295 results = results.into_iter().skip(offset).collect();
1296 } else {
1297 results.clear();
1298 }
1299 }
1300
1301 if let Some(limit) = filter.limit {
1303 results.truncate(limit);
1304 }
1305
1306 log::info!("AST query returned {} results", results.len());
1307
1308 Ok(results)
1309 }
1310
1311 pub fn list_by_kind(&self, kind: SymbolKind) -> Result<Vec<SearchResult>> {
1313 let filter = QueryFilter {
1314 kind: Some(kind),
1315 symbols_mode: true,
1316 ..Default::default()
1317 };
1318
1319 self.search("*", filter)
1320 }
1321
1322 fn enrich_with_symbols(
1343 &self,
1344 candidates: Vec<SearchResult>,
1345 pattern: &str,
1346 filter: &QueryFilter,
1347 ) -> Result<Vec<SearchResult>> {
1348 let content_path = self.cache.path().join("content.bin");
1350 let content_reader =
1351 ContentReader::open(&content_path).context("Failed to open content store")?;
1352
1353 let trigrams_path = self.cache.path().join("trigrams.bin");
1355 let trigram_index = if trigrams_path.exists() {
1356 TrigramIndex::load(&trigrams_path)?
1357 } else {
1358 Self::rebuild_trigram_index(&content_reader)?
1359 };
1360
1361 let symbol_cache = crate::symbol_cache::SymbolCache::open(self.cache.path())
1363 .context("Failed to open symbol cache")?;
1364
1365 let root = self.cache.workspace_root();
1367 let branch =
1368 crate::git::get_current_branch(&root).unwrap_or_else(|_| "_default".to_string());
1369 let file_hashes = self
1370 .cache
1371 .load_hashes_for_branch(&branch)
1372 .context("Failed to load file hashes")?;
1373 log::debug!(
1374 "Loaded {} file hashes for branch '{}' for symbol cache lookups",
1375 file_hashes.len(),
1376 branch
1377 );
1378
1379 use std::collections::HashMap;
1381 let mut files_by_path: HashMap<String, Vec<SearchResult>> = HashMap::new();
1382 let mut skipped_unsupported = 0;
1383
1384 for candidate in candidates {
1385 if !candidate.lang.is_supported() {
1387 skipped_unsupported += 1;
1388 continue;
1389 }
1390
1391 files_by_path
1392 .entry(candidate.path.clone())
1393 .or_default()
1394 .push(candidate);
1395 }
1396
1397 let total_files = files_by_path.len();
1398 log::debug!(
1399 "Processing {} candidate files for symbol enrichment (skipped {} unsupported language files)",
1400 total_files,
1401 skipped_unsupported
1402 );
1403
1404 if total_files > 1000 && !filter.suppress_output {
1406 output::warn(&format!(
1407 "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.",
1408 pattern, total_files
1409 ));
1410 }
1411
1412 let mut files_to_process: Vec<String> = files_by_path.keys().cloned().collect();
1414
1415 let mut files_to_skip: std::collections::HashSet<String> = std::collections::HashSet::new();
1418
1419 for file_path in &files_to_process {
1420 let ext = std::path::Path::new(file_path)
1422 .extension()
1423 .and_then(|e| e.to_str())
1424 .unwrap_or("");
1425 let lang = Language::from_extension(ext);
1426
1427 if let Some(line_filter) = crate::line_filter::get_filter(lang) {
1429 let file_id =
1431 match Self::find_file_id_by_path(&content_reader, &trigram_index, file_path) {
1432 Some(id) => id,
1433 None => continue,
1434 };
1435
1436 let content = match content_reader.get_file_content(file_id) {
1438 Ok(c) => c,
1439 Err(_) => continue,
1440 };
1441
1442 let mut all_in_non_code = true;
1444 for line in content.lines() {
1445 let mut search_start = 0;
1447 while let Some(pos) = line[search_start..].find(pattern) {
1448 let absolute_pos = search_start + pos;
1449
1450 let in_comment = line_filter.is_in_comment(line, absolute_pos);
1452 let in_string = line_filter.is_in_string(line, absolute_pos);
1453
1454 if !in_comment && !in_string {
1455 all_in_non_code = false;
1457 break;
1458 }
1459
1460 search_start = absolute_pos + pattern.len();
1461 }
1462
1463 if !all_in_non_code {
1464 break;
1465 }
1466 }
1467
1468 if all_in_non_code {
1470 if content.contains(pattern) {
1472 files_to_skip.insert(file_path.clone());
1473 log::debug!(
1474 "Pre-filter: Skipping {} (all matches in comments/strings)",
1475 file_path
1476 );
1477 }
1478 }
1479 }
1480 }
1481
1482 files_to_process.retain(|path| !files_to_skip.contains(path));
1484
1485 log::debug!(
1486 "Pre-filter: Skipped {} files where all matches are in comments/strings (parsing {} files)",
1487 files_to_skip.len(),
1488 files_to_process.len()
1489 );
1490
1491 let num_threads = {
1493 let available_cores = std::thread::available_parallelism()
1494 .map(|n| n.get())
1495 .unwrap_or(4);
1496 ((available_cores as f64 * 0.8).ceil() as usize).clamp(1, 8)
1499 };
1500
1501 log::debug!(
1502 "Using {} threads for parallel symbol extraction (out of {} available cores)",
1503 num_threads,
1504 std::thread::available_parallelism()
1505 .map(|n| n.get())
1506 .unwrap_or(4)
1507 );
1508
1509 let pool = rayon::ThreadPoolBuilder::new()
1511 .num_threads(num_threads)
1512 .build()
1513 .context("Failed to create thread pool for symbol extraction")?;
1514
1515 let files_with_hashes: Vec<String> = files_to_process
1520 .iter()
1521 .filter(|path| file_hashes.contains_key(path.as_str()))
1522 .cloned()
1523 .collect();
1524
1525 let file_id_map = self
1527 .cache
1528 .batch_get_file_ids(&files_with_hashes)
1529 .context("Failed to batch lookup file IDs")?;
1530
1531 let file_lookup_tuples: Vec<(i64, String, String)> = files_with_hashes
1533 .iter()
1534 .filter_map(|path| {
1535 let file_id = file_id_map.get(path)?;
1536 let hash = file_hashes.get(path.as_str())?;
1537 Some((*file_id, hash.clone(), path.clone()))
1538 })
1539 .collect();
1540
1541 let batch_results = symbol_cache
1543 .batch_get_with_kind(&file_lookup_tuples, filter.kind.clone())
1544 .context("Failed to batch read symbol cache")?;
1545
1546 let mut cached_symbols: HashMap<String, Vec<SearchResult>> = HashMap::new();
1548 let mut files_needing_parse: Vec<String> = Vec::new();
1549
1550 let id_to_path: HashMap<i64, String> = file_id_map
1552 .iter()
1553 .map(|(path, id)| (*id, path.clone()))
1554 .collect();
1555
1556 for (file_id, symbols) in batch_results {
1558 if let Some(file_path) = id_to_path.get(&file_id) {
1559 cached_symbols.insert(file_path.clone(), symbols);
1560 }
1561 }
1562
1563 for path in &files_with_hashes {
1565 if file_id_map.contains_key(path) && !cached_symbols.contains_key(path) {
1566 files_needing_parse.push(path.clone());
1567 }
1568 }
1569
1570 for file_path in &files_to_process {
1572 if !file_hashes.contains_key(file_path.as_str()) {
1573 files_needing_parse.push(file_path.clone());
1574 }
1575 }
1576
1577 log::debug!(
1578 "Symbol cache: {} hits, {} need parsing",
1579 cached_symbols.len(),
1580 files_needing_parse.len()
1581 );
1582
1583 use rayon::prelude::*;
1585
1586 let parsed_symbols: Vec<SearchResult> = pool.install(|| {
1587 files_needing_parse
1588 .par_iter()
1589 .flat_map(|file_path| {
1590 let file_id = match Self::find_file_id_by_path(
1592 &content_reader,
1593 &trigram_index,
1594 file_path,
1595 ) {
1596 Some(id) => id,
1597 None => {
1598 log::warn!("Could not find file_id for path: {}", file_path);
1599 return Vec::new();
1600 }
1601 };
1602
1603 let content = match content_reader.get_file_content(file_id) {
1604 Ok(c) => c,
1605 Err(e) => {
1606 log::warn!("Failed to read file {}: {}", file_path, e);
1607 return Vec::new();
1608 }
1609 };
1610
1611 let ext = std::path::Path::new(file_path)
1613 .extension()
1614 .and_then(|e| e.to_str())
1615 .unwrap_or("");
1616 let lang = Language::from_extension(ext);
1617
1618 let symbols = match ParserFactory::parse(file_path, content, lang) {
1620 Ok(symbols) => {
1621 log::debug!("Parsed {} symbols from {}", symbols.len(), file_path);
1622 symbols
1623 }
1624 Err(e) => {
1625 log::debug!("Failed to parse {}: {}", file_path, e);
1626 Vec::new()
1627 }
1628 };
1629
1630 if let Some(file_hash) = file_hashes.get(file_path.as_str())
1632 && let Err(e) = symbol_cache.set(file_path, file_hash, &symbols)
1633 {
1634 log::debug!("Failed to cache symbols for {}: {}", file_path, e);
1635 }
1636
1637 symbols
1638 })
1639 .collect()
1640 });
1641
1642 let mut all_symbols: Vec<SearchResult> = Vec::new();
1644
1645 for symbols in cached_symbols.values() {
1647 all_symbols.extend_from_slice(symbols);
1648 }
1649
1650 all_symbols.extend(parsed_symbols);
1652
1653 let is_keyword_query = {
1661 let lang_to_check = if let Some(lang) = filter.language {
1663 vec![lang]
1666 } else {
1667 let mut langs: Vec<Language> =
1671 all_symbols.iter().map(|s| s.lang).collect::<Vec<_>>();
1672 langs.sort_by(|a, b| format!("{:?}", a).cmp(&format!("{:?}", b))); langs.dedup(); langs
1675 };
1676
1677 lang_to_check
1679 .iter()
1680 .any(|lang| ParserFactory::get_keywords(*lang).contains(&pattern))
1681 };
1682
1683 let filtered: Vec<SearchResult> = if is_keyword_query {
1686 log::info!(
1687 "Pattern '{}' is a language keyword - listing all symbols (kind filtering will be applied in Phase 3)",
1688 pattern
1689 );
1690 all_symbols
1691 } else if filter.use_regex {
1692 use std::collections::{HashMap, HashSet};
1698 let mut candidate_lines: HashMap<String, HashSet<usize>> = HashMap::new();
1699 for candidate in &files_by_path {
1700 for cand in candidate.1 {
1701 candidate_lines
1702 .entry(candidate.0.clone())
1703 .or_default()
1704 .insert(cand.span.start_line);
1705 }
1706 }
1707
1708 all_symbols
1710 .into_iter()
1711 .filter(|sym| {
1712 if let Some(lines) = candidate_lines.get(&sym.path) {
1713 for line in sym.span.start_line..=sym.span.end_line {
1715 if lines.contains(&line) {
1716 return true;
1717 }
1718 }
1719 }
1720 false
1721 })
1722 .collect()
1723 } else if filter.use_contains {
1724 all_symbols
1726 .into_iter()
1727 .filter(|sym| sym.symbol.as_deref().is_some_and(|s| s.contains(pattern)))
1728 .collect()
1729 } else {
1730 all_symbols
1732 .into_iter()
1733 .filter(|sym| sym.symbol.as_deref() == Some(pattern))
1734 .collect()
1735 };
1736
1737 log::info!(
1738 "Symbol enrichment found {} matches for pattern '{}'",
1739 filtered.len(),
1740 pattern
1741 );
1742
1743 Ok(filtered)
1744 }
1745
1746 fn enrich_with_ast(
1765 &self,
1766 candidates: Vec<SearchResult>,
1767 ast_pattern: &str,
1768 language: Option<Language>,
1769 ) -> Result<Vec<SearchResult>> {
1770 let lang = language.ok_or_else(|| anyhow::anyhow!(
1772 "Language must be specified for AST pattern matching. Use --lang to specify the language."
1773 ))?;
1774
1775 let content_path = self.cache.path().join("content.bin");
1777 let content_reader =
1778 ContentReader::open(&content_path).context("Failed to open content store")?;
1779
1780 let trigrams_path = self.cache.path().join("trigrams.bin");
1782 let trigram_index = if trigrams_path.exists() {
1783 TrigramIndex::load(&trigrams_path)?
1784 } else {
1785 Self::rebuild_trigram_index(&content_reader)?
1786 };
1787
1788 use std::collections::HashMap;
1790 let mut file_contents: HashMap<String, String> = HashMap::new();
1791
1792 for candidate in &candidates {
1793 if file_contents.contains_key(&candidate.path) {
1794 continue;
1795 }
1796
1797 let file_id = match Self::find_file_id_by_path(
1799 &content_reader,
1800 &trigram_index,
1801 &candidate.path,
1802 ) {
1803 Some(id) => id,
1804 None => {
1805 log::warn!("Could not find file_id for path: {}", candidate.path);
1806 continue;
1807 }
1808 };
1809
1810 let content = match content_reader.get_file_content(file_id) {
1812 Ok(c) => c,
1813 Err(e) => {
1814 log::warn!("Failed to read file {}: {}", candidate.path, e);
1815 continue;
1816 }
1817 };
1818
1819 file_contents.insert(candidate.path.clone(), content.to_string());
1820 }
1821
1822 log::debug!(
1823 "Executing AST query on {} candidate files with language {:?}",
1824 file_contents.len(),
1825 lang
1826 );
1827
1828 let results =
1830 crate::ast_query::execute_ast_query(candidates, ast_pattern, lang, &file_contents)?;
1831
1832 log::info!(
1833 "AST query found {} matches for pattern '{}'",
1834 results.len(),
1835 ast_pattern
1836 );
1837
1838 Ok(results)
1839 }
1840
1841 fn find_file_id_by_path(
1843 content_reader: &ContentReader,
1844 trigram_index: &TrigramIndex,
1845 target_path: &str,
1846 ) -> Option<u32> {
1847 for file_id in 0..trigram_index.file_count() {
1849 if let Some(path) = trigram_index.get_file(file_id as u32)
1850 && path.to_string_lossy() == target_path
1851 {
1852 return Some(file_id as u32);
1853 }
1854 }
1855
1856 for file_id in 0..content_reader.file_count() {
1858 if let Some(path) = content_reader.get_file_path(file_id as u32)
1859 && path.to_string_lossy() == target_path
1860 {
1861 return Some(file_id as u32);
1862 }
1863 }
1864
1865 None
1866 }
1867
1868 fn keyword_to_kind(keyword: &str) -> Option<SymbolKind> {
1876 filter::keyword_to_kind(keyword)
1877 }
1878
1879 fn get_all_language_files(&self, filter: &QueryFilter) -> Result<Vec<SearchResult>> {
1887 let content_path = self.cache.path().join("content.bin");
1892 let content_reader =
1893 ContentReader::open(&content_path).context("Failed to open content store")?;
1894
1895 use globset::{Glob, GlobSetBuilder};
1897
1898 let include_matcher = if !filter.glob_patterns.is_empty() {
1899 let mut builder = GlobSetBuilder::new();
1900 for pattern in &filter.glob_patterns {
1901 let normalized = Self::normalize_glob_pattern(pattern);
1902 if let Ok(glob) = Glob::new(&normalized) {
1903 builder.add(glob);
1904 }
1905 }
1906 builder.build().ok()
1907 } else {
1908 None
1909 };
1910
1911 let exclude_matcher = if !filter.exclude_patterns.is_empty() {
1912 let mut builder = GlobSetBuilder::new();
1913 for pattern in &filter.exclude_patterns {
1914 let normalized = Self::normalize_glob_pattern(pattern);
1915 if let Ok(glob) = Glob::new(&normalized) {
1916 builder.add(glob);
1917 }
1918 }
1919 builder.build().ok()
1920 } else {
1921 None
1922 };
1923
1924 let mut candidates: Vec<SearchResult> = Vec::new();
1926
1927 for file_id in 0..content_reader.file_count() {
1928 let file_path = match content_reader.get_file_path(file_id as u32) {
1929 Some(p) => p,
1930 None => continue,
1931 };
1932
1933 let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
1935 let detected_lang = Language::from_extension(ext);
1936
1937 if let Some(lang) = filter.language
1939 && detected_lang != lang
1940 {
1941 continue;
1942 }
1943
1944 let file_path_str = file_path.to_string_lossy().to_string();
1945
1946 let included = include_matcher
1948 .as_ref()
1949 .is_none_or(|m| m.is_match(&file_path_str));
1950 let excluded = exclude_matcher
1951 .as_ref()
1952 .is_some_and(|m| m.is_match(&file_path_str));
1953
1954 if !included || excluded {
1955 continue;
1956 }
1957
1958 if let Some(ref file_pattern) = filter.file_pattern
1960 && !file_path_str.contains(file_pattern)
1961 {
1962 continue;
1963 }
1964
1965 candidates.push(SearchResult {
1968 path: file_path_str,
1969 lang: detected_lang,
1970 span: Span {
1971 start_line: 1,
1972 end_line: 1,
1973 },
1974 symbol: None,
1975 kind: SymbolKind::Unknown("keyword_query".to_string()),
1976 preview: String::new(),
1977 dependencies: None,
1978 });
1979 }
1980
1981 if let Some(lang) = filter.language {
1982 log::info!(
1983 "Keyword query will scan {} {:?} files for symbol extraction",
1984 candidates.len(),
1985 lang
1986 );
1987 } else {
1988 log::info!(
1989 "Keyword query will scan {} files (all languages) for symbol extraction",
1990 candidates.len()
1991 );
1992 }
1993
1994 Ok(candidates)
1995 }
1996
1997 fn get_trigram_candidates(
1999 &self,
2000 pattern: &str,
2001 filter: &QueryFilter,
2002 ) -> Result<Vec<SearchResult>> {
2003 let content_path = self.cache.path().join("content.bin");
2005 let content_reader =
2006 ContentReader::open(&content_path).context("Failed to open content store")?;
2007
2008 if pattern.chars().count() < 3 {
2012 log::info!(
2013 "Pattern '{}' is shorter than 3 chars — trigram index cannot be used, \
2014 falling back to linear scan",
2015 pattern
2016 );
2017 return self.linear_scan_candidates(pattern, filter, &content_reader);
2018 }
2019
2020 let trigrams_path = self.cache.path().join("trigrams.bin");
2022 let trigram_index = if trigrams_path.exists() {
2023 match TrigramIndex::load(&trigrams_path) {
2024 Ok(index) => {
2025 log::debug!(
2026 "Loaded trigram index from disk: {} trigrams, {} files",
2027 index.trigram_count(),
2028 index.file_count()
2029 );
2030 index
2031 }
2032 Err(e) => {
2033 log::warn!("Failed to load trigram index from disk: {}", e);
2034 log::warn!("Rebuilding trigram index from content store...");
2035 Self::rebuild_trigram_index(&content_reader)?
2036 }
2037 }
2038 } else {
2039 log::debug!("trigrams.bin not found, rebuilding from content store");
2040 Self::rebuild_trigram_index(&content_reader)?
2041 };
2042
2043 let candidates = trigram_index.search(pattern);
2045 log::debug!(
2046 "Found {} candidate locations from trigram search",
2047 candidates.len()
2048 );
2049
2050 let pattern_owned = pattern.to_string();
2052
2053 let compiled_regex = if filter.use_regex {
2055 match Regex::new(&pattern_owned) {
2056 Ok(re) => Some(re),
2057 Err(e) => {
2058 log::error!("Invalid regex pattern '{}': {}", pattern_owned, e);
2059 anyhow::bail!("Invalid regex pattern '{}': {}", pattern_owned, e);
2060 }
2061 }
2062 } else {
2063 None
2064 };
2065
2066 use std::collections::HashMap;
2068 let mut candidates_by_file: HashMap<u32, Vec<crate::trigram::FileLocation>> =
2069 HashMap::new();
2070 for loc in candidates {
2071 candidates_by_file.entry(loc.file_id).or_default().push(loc);
2072 }
2073
2074 log::debug!(
2075 "Scanning {} files with trigram matches",
2076 candidates_by_file.len()
2077 );
2078
2079 use rayon::prelude::*;
2081
2082 let results: Vec<SearchResult> = candidates_by_file
2083 .par_iter()
2084 .flat_map(|(file_id, locations)| {
2085 let file_path = match trigram_index.get_file(*file_id) {
2087 Some(p) => p,
2088 None => return Vec::new(),
2089 };
2090
2091 let content = match content_reader.get_file_content(*file_id) {
2092 Ok(c) => c,
2093 Err(_) => return Vec::new(),
2094 };
2095
2096 let file_path_str = file_path.to_string_lossy().to_string();
2097
2098 let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
2100 let lang = Language::from_extension(ext);
2101
2102 let lines: Vec<&str> = content.lines().collect();
2104
2105 let mut seen_lines: std::collections::HashSet<usize> =
2107 std::collections::HashSet::new();
2108 let mut file_results = Vec::new();
2109
2110 for loc in locations {
2112 let line_no = loc.line_no as usize;
2113
2114 if seen_lines.contains(&line_no) {
2116 continue;
2117 }
2118
2119 if line_no == 0 || line_no > lines.len() {
2121 log::debug!(
2122 "Line {} out of bounds (file has {} lines)",
2123 line_no,
2124 lines.len()
2125 );
2126 continue;
2127 }
2128
2129 let line = lines[line_no - 1];
2130
2131 let line_matches = if filter.use_regex {
2136 compiled_regex
2139 .as_ref()
2140 .map(|re| re.is_match(line))
2141 .unwrap_or(false)
2142 } else if filter.use_contains {
2143 line.contains(&pattern_owned)
2145 } else {
2146 Self::has_word_boundary_match(line, &pattern_owned)
2148 };
2149
2150 if !line_matches {
2151 continue;
2152 }
2153
2154 seen_lines.insert(line_no);
2155
2156 file_results.push(SearchResult {
2158 path: file_path_str.clone(),
2159 lang,
2160 kind: SymbolKind::Unknown("text_match".to_string()),
2161 symbol: None, span: Span {
2163 start_line: line_no,
2164 end_line: line_no,
2165 },
2166 preview: line.to_string(),
2167 dependencies: None,
2168 });
2169 }
2170
2171 file_results
2172 })
2173 .collect();
2174
2175 Ok(results)
2176 }
2177
2178 fn linear_scan_candidates(
2185 &self,
2186 pattern: &str,
2187 filter: &QueryFilter,
2188 content_reader: &ContentReader,
2189 ) -> Result<Vec<SearchResult>> {
2190 use rayon::prelude::*;
2191
2192 let pattern_owned = pattern.to_string();
2193 let file_count = content_reader.file_count();
2194
2195 let compiled_regex = if filter.use_regex {
2196 match Regex::new(&pattern_owned) {
2197 Ok(re) => Some(re),
2198 Err(e) => anyhow::bail!("Invalid regex pattern '{}': {}", pattern_owned, e),
2199 }
2200 } else {
2201 None
2202 };
2203
2204 let results: Vec<SearchResult> = (0..file_count as u32)
2205 .collect::<Vec<_>>()
2206 .par_iter()
2207 .flat_map(|&file_id| {
2208 let file_path = match content_reader.get_file_path(file_id) {
2209 Some(p) => p.to_path_buf(),
2210 None => return Vec::new(),
2211 };
2212 let content = match content_reader.get_file_content(file_id) {
2213 Ok(c) => c,
2214 Err(_) => return Vec::new(),
2215 };
2216
2217 let file_path_str = file_path.to_string_lossy().to_string();
2218 let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
2219 let lang = Language::from_extension(ext);
2220
2221 let mut seen_lines = std::collections::HashSet::new();
2222 let mut file_results = Vec::new();
2223
2224 for (line_idx, line) in content.lines().enumerate() {
2225 let line_no = line_idx + 1;
2226 if seen_lines.contains(&line_no) {
2227 continue;
2228 }
2229
2230 let line_matches = if filter.use_regex {
2231 compiled_regex
2232 .as_ref()
2233 .map(|re| re.is_match(line))
2234 .unwrap_or(false)
2235 } else if filter.use_contains {
2236 line.contains(&pattern_owned)
2237 } else {
2238 Self::has_word_boundary_match(line, &pattern_owned)
2239 };
2240
2241 if !line_matches {
2242 continue;
2243 }
2244
2245 seen_lines.insert(line_no);
2246 file_results.push(SearchResult {
2247 path: file_path_str.clone(),
2248 lang,
2249 kind: SymbolKind::Unknown("text_match".to_string()),
2250 symbol: None,
2251 span: Span {
2252 start_line: line_no,
2253 end_line: line_no,
2254 },
2255 preview: line.to_string(),
2256 dependencies: None,
2257 });
2258 }
2259
2260 file_results
2261 })
2262 .collect();
2263
2264 log::info!(
2265 "Linear scan (short pattern '{}') found {} results across {} files",
2266 pattern,
2267 results.len(),
2268 file_count
2269 );
2270 Ok(results)
2271 }
2272
2273 fn get_regex_candidates(
2297 &self,
2298 pattern: &str,
2299 timeout: Option<&std::time::Duration>,
2300 start_time: &std::time::Instant,
2301 suppress_output: bool,
2302 ) -> Result<Vec<SearchResult>> {
2303 let regex =
2305 Regex::new(pattern).with_context(|| format!("Invalid regex pattern: {}", pattern))?;
2306
2307 if let Some(timeout_duration) = timeout
2309 && start_time.elapsed() > *timeout_duration
2310 {
2311 anyhow::bail!(
2312 "Query timeout exceeded ({} seconds) during regex compilation",
2313 timeout_duration.as_secs()
2314 );
2315 }
2316
2317 let trigrams = extract_trigrams_from_regex(pattern);
2319
2320 let content_path = self.cache.path().join("content.bin");
2322 let content_reader =
2323 ContentReader::open(&content_path).context("Failed to open content store")?;
2324
2325 let mut results = Vec::new();
2326
2327 if trigrams.is_empty() {
2328 if !suppress_output {
2330 output::warn(&format!(
2331 "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.",
2332 pattern
2333 ));
2334 }
2335
2336 for file_id in 0..content_reader.file_count() {
2338 let file_path = content_reader
2339 .get_file_path(file_id as u32)
2340 .context("Invalid file_id")?;
2341 let content = content_reader.get_file_content(file_id as u32)?;
2342
2343 self.find_regex_matches_in_file(®ex, file_path, content, &mut results)?;
2344 }
2345 } else {
2346 log::debug!(
2348 "Using {} trigrams to narrow regex search candidates",
2349 trigrams.len()
2350 );
2351
2352 let trigrams_path = self.cache.path().join("trigrams.bin");
2354 let trigram_index = if trigrams_path.exists() {
2355 TrigramIndex::load(&trigrams_path)?
2356 } else {
2357 Self::rebuild_trigram_index(&content_reader)?
2358 };
2359
2360 use crate::regex_trigrams::extract_literal_sequences;
2362 let literals = extract_literal_sequences(pattern);
2363
2364 if literals.is_empty() {
2365 log::warn!(
2366 "Regex extraction found trigrams but no literal sequences - this shouldn't happen"
2367 );
2368 for file_id in 0..content_reader.file_count() {
2370 let file_path = content_reader
2371 .get_file_path(file_id as u32)
2372 .context("Invalid file_id")?;
2373 let content = content_reader.get_file_content(file_id as u32)?;
2374 self.find_regex_matches_in_file(®ex, file_path, content, &mut results)?;
2375 }
2376 } else {
2377 use std::collections::HashSet;
2382 let mut candidate_files: HashSet<u32> = HashSet::new();
2383
2384 for literal in &literals {
2385 let candidates = trigram_index.search(literal);
2387 let file_ids: HashSet<u32> = candidates.iter().map(|loc| loc.file_id).collect();
2388
2389 log::debug!("Literal '{}' found in {} files", literal, file_ids.len());
2390
2391 candidate_files.extend(file_ids);
2394 }
2395
2396 let final_candidates = candidate_files;
2397 log::debug!(
2398 "After union: searching {} files that contain any literal",
2399 final_candidates.len()
2400 );
2401
2402 for &file_id in &final_candidates {
2404 let file_path = trigram_index
2405 .get_file(file_id)
2406 .context("Invalid file_id from trigram search")?;
2407 let content = content_reader.get_file_content(file_id)?;
2408
2409 self.find_regex_matches_in_file(®ex, file_path, content, &mut results)?;
2410 }
2411 }
2412 }
2413
2414 log::info!(
2415 "Regex search found {} matches for pattern '{}'",
2416 results.len(),
2417 pattern
2418 );
2419 Ok(results)
2420 }
2421
2422 fn find_regex_matches_in_file(
2424 &self,
2425 regex: &Regex,
2426 file_path: &std::path::Path,
2427 content: &str,
2428 results: &mut Vec<SearchResult>,
2429 ) -> Result<()> {
2430 let file_path_str = file_path.to_string_lossy().to_string();
2431
2432 let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
2434 let lang = Language::from_extension(ext);
2435
2436 for (line_idx, line) in content.lines().enumerate() {
2438 if regex.is_match(line) {
2439 let line_no = line_idx + 1;
2440
2441 results.push(SearchResult {
2448 path: file_path_str.clone(),
2449 lang,
2450 kind: SymbolKind::Unknown("regex_match".to_string()),
2451 symbol: None, span: Span {
2453 start_line: line_no,
2454 end_line: line_no,
2455 },
2456 preview: line.to_string(),
2457 dependencies: None,
2458 });
2459 }
2460 }
2461
2462 Ok(())
2463 }
2464
2465 fn find_file_id(content_reader: &ContentReader, target_path: &str) -> Option<u32> {
2466 result::find_file_id(content_reader, target_path)
2467 }
2468
2469 fn rebuild_trigram_index(content_reader: &ContentReader) -> Result<TrigramIndex> {
2470 result::rebuild_trigram_index(content_reader)
2471 }
2472
2473 fn normalize_glob_pattern(pattern: &str) -> String {
2474 result::normalize_glob_pattern(pattern)
2475 }
2476
2477 fn has_word_boundary_match(line: &str, pattern: &str) -> bool {
2478 filter::has_word_boundary_match(line, pattern)
2479 }
2480
2481 pub fn get_index_status(&self) -> Result<(IndexStatus, bool, Option<IndexWarning>)> {
2486 let root = self.cache.workspace_root();
2487
2488 if crate::git::is_git_repo(&root)
2490 && let Ok(current_branch) = crate::git::get_current_branch(&root)
2491 {
2492 if !self.cache.branch_exists(¤t_branch).unwrap_or(false) {
2494 let warning = IndexWarning {
2495 reason: format!("Branch '{}' has not been indexed", current_branch),
2496 action_required: "rfx index".to_string(),
2497 files_modified: None,
2498 details: Some(IndexWarningDetails {
2499 current_branch: Some(current_branch),
2500 indexed_branch: None,
2501 current_commit: None,
2502 indexed_commit: None,
2503 }),
2504 };
2505 return Ok((IndexStatus::Stale, false, Some(warning)));
2506 }
2507
2508 if let (Ok(current_commit), Ok(branch_info)) = (
2510 crate::git::get_current_commit(&root),
2511 self.cache.get_branch_info(¤t_branch),
2512 ) {
2513 if branch_info.commit_sha != current_commit {
2514 let warning = IndexWarning {
2515 reason: format!(
2516 "Commit changed from {} to {}",
2517 &branch_info.commit_sha[..7],
2518 ¤t_commit[..7]
2519 ),
2520 action_required: "rfx index".to_string(),
2521 files_modified: None,
2522 details: Some(IndexWarningDetails {
2523 current_branch: Some(current_branch.clone()),
2524 indexed_branch: Some(current_branch.clone()),
2525 current_commit: Some(current_commit.clone()),
2526 indexed_commit: Some(branch_info.commit_sha.clone()),
2527 }),
2528 };
2529 return Ok((IndexStatus::Stale, false, Some(warning)));
2530 }
2531
2532 if let Ok(branch_files) = self.cache.get_branch_files(¤t_branch) {
2534 let mut checked = 0;
2535 let mut changed = 0;
2536 const SAMPLE_SIZE: usize = 10;
2537
2538 for (path, _indexed_hash) in branch_files.iter().take(SAMPLE_SIZE) {
2539 checked += 1;
2540 let file_path = std::path::Path::new(path);
2541
2542 if let Ok(metadata) = std::fs::metadata(file_path)
2543 && let Ok(modified) = metadata.modified()
2544 {
2545 let indexed_time = branch_info.last_indexed;
2546 let file_time = modified
2547 .duration_since(std::time::UNIX_EPOCH)
2548 .unwrap_or_default()
2549 .as_secs() as i64;
2550
2551 if file_time > indexed_time {
2552 changed += 1;
2555 }
2556 }
2557 }
2558
2559 if changed > 0 {
2560 let warning = IndexWarning {
2561 reason: format!("{} of {} sampled files modified", changed, checked),
2562 action_required: "rfx index".to_string(),
2563 files_modified: Some(changed as u32),
2564 details: Some(IndexWarningDetails {
2565 current_branch: Some(current_branch.clone()),
2566 indexed_branch: Some(branch_info.branch.clone()),
2567 current_commit: Some(current_commit.clone()),
2568 indexed_commit: Some(branch_info.commit_sha.clone()),
2569 }),
2570 };
2571 return Ok((IndexStatus::Stale, false, Some(warning)));
2572 }
2573 }
2574
2575 return Ok((IndexStatus::Fresh, true, None));
2577 }
2578 }
2579
2580 Ok((IndexStatus::Fresh, true, None))
2582 }
2583
2584 fn check_index_freshness(&self, filter: &QueryFilter) -> Result<()> {
2591 let root = self.cache.workspace_root();
2592
2593 if crate::git::is_git_repo(&root) {
2595 if !crate::git::is_git_available() {
2596 static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
2597 if !filter.suppress_output {
2598 WARNED.get_or_init(|| {
2599 output::warn("⚠️ git binary not found in PATH; index freshness checks disabled for this session.");
2600 });
2601 }
2602 return Ok(());
2603 }
2604 if let Ok(current_branch) = crate::git::get_current_branch(&root) {
2605 if !self.cache.branch_exists(¤t_branch).unwrap_or(false) {
2607 if !filter.suppress_output {
2608 output::warn(&format!(
2609 "⚠️ WARNING: Index not found for branch '{}'. Run 'rfx index' to index this branch.",
2610 current_branch
2611 ));
2612 }
2613 return Ok(());
2614 }
2615
2616 if let (Ok(current_commit), Ok(branch_info)) = (
2618 crate::git::get_current_commit(&root),
2619 self.cache.get_branch_info(¤t_branch),
2620 ) {
2621 if branch_info.commit_sha != current_commit {
2622 if !filter.suppress_output {
2623 output::warn(&format!(
2624 "⚠️ WARNING: Index may be stale (commit changed: {} → {}). Consider running 'rfx index'.",
2625 &branch_info.commit_sha[..7],
2626 ¤t_commit[..7]
2627 ));
2628 }
2629 return Ok(());
2630 }
2631
2632 if let Ok(branch_files) = self.cache.get_branch_files(¤t_branch) {
2635 let mut checked = 0;
2636 let mut changed = 0;
2637 const SAMPLE_SIZE: usize = 10;
2638
2639 for (path, _indexed_hash) in branch_files.iter().take(SAMPLE_SIZE) {
2640 checked += 1;
2641 let file_path = std::path::Path::new(path);
2642
2643 if let Ok(metadata) = std::fs::metadata(file_path)
2645 && let Ok(modified) = metadata.modified()
2646 {
2647 let indexed_time = branch_info.last_indexed;
2648 let file_time = modified
2649 .duration_since(std::time::UNIX_EPOCH)
2650 .unwrap_or_default()
2651 .as_secs()
2652 as i64;
2653
2654 if file_time > indexed_time {
2656 changed += 1;
2661 }
2662 }
2663 }
2664
2665 if changed > 0 && !filter.suppress_output {
2666 output::warn(&format!(
2667 "⚠️ WARNING: {} of {} sampled files changed since indexing. Consider running 'rfx index'.",
2668 changed, checked
2669 ));
2670 }
2671 }
2672 }
2673 }
2674 }
2675
2676 Ok(())
2677 }
2678}
2679
2680#[allow(clippy::too_many_arguments)]
2685pub fn generate_ai_instruction(
2686 result_count: usize,
2687 total_count: usize,
2688 has_more: bool,
2689 symbols_mode: bool,
2690 paths_only: bool,
2691 use_ast: bool,
2692 use_regex: bool,
2693 language_filter: bool,
2694 glob_filter: bool,
2695 exact_mode: bool,
2696) -> Option<String> {
2697 if result_count == 0 {
2699 return Some(
2700 "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."
2701 .to_string()
2702 );
2703 }
2704
2705 if total_count >= 500 {
2707 return Some(format!(
2708 "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.",
2709 total_count
2710 ));
2711 }
2712
2713 if has_more {
2721 return Some(format!(
2722 "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.",
2723 result_count,
2724 total_count,
2725 total_count.saturating_sub(result_count),
2726 result_count
2727 ));
2728 }
2729
2730 if result_count == 1 && symbols_mode {
2732 return Some(
2733 "Found 1 precise result. Respond concisely: '[symbol] at [path]:[line]'.".to_string(),
2734 );
2735 }
2736
2737 if (2..=10).contains(&result_count) && symbols_mode {
2739 return Some(format!(
2740 "Found {} precise results (definitions only, not usages). List locations concisely: '[symbol] at [path]:[line]' for each result.",
2741 result_count
2742 ));
2743 }
2744
2745 if (101..500).contains(&total_count) {
2747 return Some(format!(
2748 "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.",
2749 total_count
2750 ));
2751 }
2752
2753 if result_count >= 100 && !symbols_mode {
2755 return Some(format!(
2756 "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%.",
2757 result_count
2758 ));
2759 }
2760
2761 if paths_only {
2763 return Some(format!(
2764 "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.",
2765 result_count
2766 ));
2767 }
2768
2769 if use_ast {
2771 return Some(format!(
2772 "Found {} results using AST pattern matching. These are structure-based matches using Tree-sitter patterns, not text search.",
2773 result_count
2774 ));
2775 }
2776
2777 if use_regex && result_count >= 100 {
2779 return Some(format!(
2780 "Found {} results using regex pattern matching. Regex matches are expansive. Consider using exact text search or symbols mode for more precise results.",
2781 result_count
2782 ));
2783 }
2784
2785 if language_filter && result_count <= 5 {
2787 return Some(format!(
2788 "Found {} results with language filter active. Results are limited to this language only. Remove lang parameter if you want to search all languages.",
2789 result_count
2790 ));
2791 }
2792
2793 if glob_filter && result_count <= 10 {
2795 return Some(format!(
2796 "Found {} results with glob filter active. Results are limited to matching paths. Remove glob parameter to search entire codebase.",
2797 result_count
2798 ));
2799 }
2800
2801 if exact_mode && result_count <= 5 {
2803 return Some(format!(
2804 "Found {} results in exact match mode. Only exact symbol name matches are included. Remove exact parameter to allow substring matching.",
2805 result_count
2806 ));
2807 }
2808
2809 None
2811}
2812
2813#[cfg(test)]
2814mod tests {
2815 use super::*;
2816 use crate::indexer::Indexer;
2817 use crate::models::IndexConfig;
2818 use std::fs;
2819 use tempfile::TempDir;
2820
2821 #[test]
2824 fn test_query_engine_creation() {
2825 let temp = TempDir::new().unwrap();
2826 let cache = CacheManager::new(temp.path());
2827 let engine = QueryEngine::new(cache);
2828
2829 assert!(engine.cache.path().ends_with(".reflex"));
2830 }
2831
2832 #[test]
2833 fn test_filter_modes() {
2834 let filter_fulltext = QueryFilter::default();
2836 assert!(!filter_fulltext.symbols_mode);
2837
2838 let filter_symbols = QueryFilter {
2839 symbols_mode: true,
2840 ..Default::default()
2841 };
2842 assert!(filter_symbols.symbols_mode);
2843
2844 let filter_with_kind = QueryFilter {
2846 kind: Some(SymbolKind::Function),
2847 symbols_mode: true,
2848 ..Default::default()
2849 };
2850 assert!(filter_with_kind.symbols_mode);
2851 }
2852
2853 #[test]
2856 fn test_fulltext_search() {
2857 let temp = TempDir::new().unwrap();
2858 let project = temp.path().join("project");
2859 fs::create_dir(&project).unwrap();
2860
2861 fs::write(
2863 project.join("main.rs"),
2864 "fn main() {\n println!(\"hello\");\n}",
2865 )
2866 .unwrap();
2867 fs::write(project.join("lib.rs"), "pub fn hello() {}").unwrap();
2868
2869 let cache = CacheManager::new(&project);
2871 let indexer = Indexer::new(cache, IndexConfig::default());
2872 indexer.index(&project, false).unwrap();
2873
2874 let cache = CacheManager::new(&project);
2876 let engine = QueryEngine::new(cache);
2877 let filter = QueryFilter::default(); let results = engine.search("hello", filter).unwrap();
2879
2880 assert!(results.len() >= 2);
2882 assert!(results.iter().any(|r| r.path.contains("main.rs")));
2883 assert!(results.iter().any(|r| r.path.contains("lib.rs")));
2884 }
2885
2886 #[test]
2887 fn test_symbol_search() {
2888 let temp = TempDir::new().unwrap();
2889 let project = temp.path().join("project");
2890 fs::create_dir(&project).unwrap();
2891
2892 fs::write(
2894 project.join("main.rs"),
2895 "fn greet() {}\nfn main() {\n greet();\n}",
2896 )
2897 .unwrap();
2898
2899 let cache = CacheManager::new(&project);
2901 let indexer = Indexer::new(cache, IndexConfig::default());
2902 indexer.index(&project, false).unwrap();
2903
2904 let cache = CacheManager::new(&project);
2905
2906 let engine = QueryEngine::new(cache);
2908 let filter = QueryFilter {
2909 symbols_mode: true,
2910 ..Default::default()
2911 };
2912 let results = engine.search("greet", filter).unwrap();
2913
2914 assert!(!results.is_empty());
2916 assert!(results.iter().any(|r| r.kind == SymbolKind::Function));
2917 }
2918
2919 #[test]
2920 fn test_regex_search() {
2921 let temp = TempDir::new().unwrap();
2922 let project = temp.path().join("project");
2923 fs::create_dir(&project).unwrap();
2924
2925 fs::write(
2926 project.join("main.rs"),
2927 "fn test1() {}\nfn test2() {}\nfn other() {}",
2928 )
2929 .unwrap();
2930
2931 let cache = CacheManager::new(&project);
2932 let indexer = Indexer::new(cache, IndexConfig::default());
2933 indexer.index(&project, false).unwrap();
2934
2935 let cache = CacheManager::new(&project);
2936
2937 let engine = QueryEngine::new(cache);
2938 let filter = QueryFilter {
2939 use_regex: true,
2940 ..Default::default()
2941 };
2942 let results = engine.search(r"fn test\d", filter).unwrap();
2943
2944 assert_eq!(results.len(), 2);
2946 assert!(results.iter().all(|r| r.preview.contains("test")));
2947 }
2948
2949 #[test]
2952 fn test_language_filter() {
2953 let temp = TempDir::new().unwrap();
2954 let project = temp.path().join("project");
2955 fs::create_dir(&project).unwrap();
2956
2957 fs::write(project.join("main.rs"), "fn main() {}").unwrap();
2958 fs::write(project.join("main.js"), "function main() {}").unwrap();
2959
2960 let cache = CacheManager::new(&project);
2961 let indexer = Indexer::new(cache, IndexConfig::default());
2962 indexer.index(&project, false).unwrap();
2963
2964 let cache = CacheManager::new(&project);
2965
2966 let engine = QueryEngine::new(cache);
2967
2968 let filter = QueryFilter {
2970 language: Some(Language::Rust),
2971 ..Default::default()
2972 };
2973 let results = engine.search("main", filter).unwrap();
2974
2975 assert!(results.iter().all(|r| r.lang == Language::Rust));
2976 assert!(results.iter().all(|r| r.path.ends_with(".rs")));
2977 }
2978
2979 #[test]
2980 fn test_kind_filter() {
2981 let temp = TempDir::new().unwrap();
2982 let project = temp.path().join("project");
2983 fs::create_dir(&project).unwrap();
2984
2985 fs::write(
2986 project.join("main.rs"),
2987 "struct Point {}\nfn main() {}\nimpl Point { fn new() {} }",
2988 )
2989 .unwrap();
2990
2991 let cache = CacheManager::new(&project);
2992 let indexer = Indexer::new(cache, IndexConfig::default());
2993 indexer.index(&project, false).unwrap();
2994
2995 let cache = CacheManager::new(&project);
2996
2997 let engine = QueryEngine::new(cache);
2998
2999 let filter = QueryFilter {
3001 symbols_mode: true,
3002 kind: Some(SymbolKind::Function),
3003 use_contains: true, ..Default::default()
3005 };
3006 let results = engine.search("mai", filter).unwrap();
3008
3009 assert!(!results.is_empty(), "Should find at least one result");
3011 assert!(
3012 results.iter().any(|r| r.symbol.as_deref() == Some("main")),
3013 "Should find 'main' function"
3014 );
3015 }
3016
3017 #[test]
3018 fn test_file_pattern_filter() {
3019 let temp = TempDir::new().unwrap();
3020 let project = temp.path().join("project");
3021 fs::create_dir_all(project.join("src")).unwrap();
3022 fs::create_dir_all(project.join("tests")).unwrap();
3023
3024 fs::write(project.join("src/lib.rs"), "fn foo() {}").unwrap();
3025 fs::write(project.join("tests/test.rs"), "fn foo() {}").unwrap();
3026
3027 let cache = CacheManager::new(&project);
3028 let indexer = Indexer::new(cache, IndexConfig::default());
3029 indexer.index(&project, false).unwrap();
3030
3031 let cache = CacheManager::new(&project);
3032
3033 let engine = QueryEngine::new(cache);
3034
3035 let filter = QueryFilter {
3037 file_pattern: Some("src/".to_string()),
3038 ..Default::default()
3039 };
3040 let results = engine.search("foo", filter).unwrap();
3041
3042 assert!(results.iter().all(|r| r.path.contains("src/")));
3043 assert!(!results.iter().any(|r| r.path.contains("tests/")));
3044 }
3045
3046 #[test]
3047 fn test_limit_filter() {
3048 let temp = TempDir::new().unwrap();
3049 let project = temp.path().join("project");
3050 fs::create_dir(&project).unwrap();
3051
3052 let content = (0..20)
3054 .map(|i| format!("fn test{}() {{}}", i))
3055 .collect::<Vec<_>>()
3056 .join("\n");
3057 fs::write(project.join("main.rs"), content).unwrap();
3058
3059 let cache = CacheManager::new(&project);
3060 let indexer = Indexer::new(cache, IndexConfig::default());
3061 indexer.index(&project, false).unwrap();
3062
3063 let cache = CacheManager::new(&project);
3064
3065 let engine = QueryEngine::new(cache);
3066
3067 let filter = QueryFilter {
3069 limit: Some(5),
3070 use_contains: true, ..Default::default()
3072 };
3073 let results = engine.search("test", filter).unwrap();
3074
3075 assert_eq!(results.len(), 5);
3076 }
3077
3078 #[test]
3079 fn test_exact_match_filter() {
3080 let temp = TempDir::new().unwrap();
3081 let project = temp.path().join("project");
3082 fs::create_dir(&project).unwrap();
3083
3084 fs::write(
3085 project.join("main.rs"),
3086 "fn test() {}\nfn test_helper() {}\nfn other_test() {}",
3087 )
3088 .unwrap();
3089
3090 let cache = CacheManager::new(&project);
3091 let indexer = Indexer::new(cache, IndexConfig::default());
3092 indexer.index(&project, false).unwrap();
3093
3094 let cache = CacheManager::new(&project);
3095
3096 let engine = QueryEngine::new(cache);
3097
3098 let filter = QueryFilter {
3100 symbols_mode: true,
3101 exact: true,
3102 ..Default::default()
3103 };
3104 let results = engine.search("test", filter).unwrap();
3105
3106 assert_eq!(results.len(), 1);
3108 assert_eq!(results[0].symbol.as_deref(), Some("test"));
3109 }
3110
3111 #[test]
3114 fn test_expand_mode() {
3115 let temp = TempDir::new().unwrap();
3116 let project = temp.path().join("project");
3117 fs::create_dir(&project).unwrap();
3118
3119 fs::write(
3120 project.join("main.rs"),
3121 "fn greet() {\n println!(\"Hello\");\n println!(\"World\");\n}",
3122 )
3123 .unwrap();
3124
3125 let cache = CacheManager::new(&project);
3126 let indexer = Indexer::new(cache, IndexConfig::default());
3127 indexer.index(&project, false).unwrap();
3128
3129 let cache = CacheManager::new(&project);
3130
3131 let engine = QueryEngine::new(cache);
3132
3133 let filter = QueryFilter {
3135 symbols_mode: true,
3136 expand: true,
3137 ..Default::default()
3138 };
3139 let results = engine.search("greet", filter).unwrap();
3140
3141 assert!(!results.is_empty());
3143 let result = &results[0];
3144 assert!(result.preview.contains("println"));
3145 }
3146
3147 #[test]
3150 fn test_search_empty_index() {
3151 let temp = TempDir::new().unwrap();
3152 let project = temp.path().join("project");
3153 fs::create_dir(&project).unwrap();
3154
3155 let cache = CacheManager::new(&project);
3156 let indexer = Indexer::new(cache, IndexConfig::default());
3157 indexer.index(&project, false).unwrap();
3158
3159 let cache = CacheManager::new(&project);
3160
3161 let engine = QueryEngine::new(cache);
3162 let filter = QueryFilter::default();
3163 let results = engine.search("nonexistent", filter).unwrap();
3164
3165 assert_eq!(results.len(), 0);
3166 }
3167
3168 #[test]
3169 fn test_search_no_index() {
3170 let temp = TempDir::new().unwrap();
3171 let project = temp.path().join("project");
3172 fs::create_dir(&project).unwrap();
3173
3174 let cache = CacheManager::new(&project);
3175 let engine = QueryEngine::new(cache);
3176 let filter = QueryFilter::default();
3177
3178 assert!(engine.search("test", filter).is_err());
3180 }
3181
3182 #[test]
3183 fn test_search_special_characters() {
3184 let temp = TempDir::new().unwrap();
3185 let project = temp.path().join("project");
3186 fs::create_dir(&project).unwrap();
3187
3188 fs::write(project.join("main.rs"), "let x = 42;\nlet y = x + 1;").unwrap();
3189
3190 let cache = CacheManager::new(&project);
3191 let indexer = Indexer::new(cache, IndexConfig::default());
3192 indexer.index(&project, false).unwrap();
3193
3194 let cache = CacheManager::new(&project);
3195
3196 let engine = QueryEngine::new(cache);
3197 let filter = QueryFilter::default();
3198
3199 let results = engine.search("x + ", filter).unwrap();
3201 assert!(!results.is_empty());
3202 }
3203
3204 #[test]
3205 fn test_search_unicode() {
3206 let temp = TempDir::new().unwrap();
3207 let project = temp.path().join("project");
3208 fs::create_dir(&project).unwrap();
3209
3210 fs::write(project.join("main.rs"), "// 你好世界\nfn main() {}").unwrap();
3211
3212 let cache = CacheManager::new(&project);
3213 let indexer = Indexer::new(cache, IndexConfig::default());
3214 indexer.index(&project, false).unwrap();
3215
3216 let cache = CacheManager::new(&project);
3217
3218 let engine = QueryEngine::new(cache);
3219 let filter = QueryFilter {
3220 use_contains: true, force: true, ..Default::default()
3223 };
3224
3225 let results = engine.search("你好", filter).unwrap();
3227 assert!(!results.is_empty());
3228 }
3229
3230 #[test]
3231 fn test_case_sensitive_search() {
3232 let temp = TempDir::new().unwrap();
3233 let project = temp.path().join("project");
3234 fs::create_dir(&project).unwrap();
3235
3236 fs::write(project.join("main.rs"), "fn Test() {}\nfn test() {}").unwrap();
3237
3238 let cache = CacheManager::new(&project);
3239 let indexer = Indexer::new(cache, IndexConfig::default());
3240 indexer.index(&project, false).unwrap();
3241
3242 let cache = CacheManager::new(&project);
3243
3244 let engine = QueryEngine::new(cache);
3245 let filter = QueryFilter::default();
3246
3247 let results = engine.search("Test", filter).unwrap();
3249 assert!(results.iter().any(|r| r.preview.contains("Test()")));
3250 }
3251
3252 #[test]
3255 fn test_results_sorted_deterministically() {
3256 let temp = TempDir::new().unwrap();
3257 let project = temp.path().join("project");
3258 fs::create_dir(&project).unwrap();
3259
3260 fs::write(project.join("a.rs"), "fn test() {}").unwrap();
3261 fs::write(project.join("z.rs"), "fn test() {}").unwrap();
3262 fs::write(project.join("m.rs"), "fn test() {}\nfn test2() {}").unwrap();
3263
3264 let cache = CacheManager::new(&project);
3265 let indexer = Indexer::new(cache, IndexConfig::default());
3266 indexer.index(&project, false).unwrap();
3267
3268 let cache = CacheManager::new(&project);
3269
3270 let engine = QueryEngine::new(cache);
3271 let filter = QueryFilter::default();
3272
3273 let results1 = engine.search("test", filter.clone()).unwrap();
3275 let results2 = engine.search("test", filter.clone()).unwrap();
3276 let results3 = engine.search("test", filter).unwrap();
3277
3278 assert_eq!(results1.len(), results2.len());
3280 assert_eq!(results1.len(), results3.len());
3281
3282 for i in 0..results1.len() {
3283 assert_eq!(results1[i].path, results2[i].path);
3284 assert_eq!(results1[i].path, results3[i].path);
3285 assert_eq!(results1[i].span.start_line, results2[i].span.start_line);
3286 assert_eq!(results1[i].span.start_line, results3[i].span.start_line);
3287 }
3288
3289 for i in 0..results1.len().saturating_sub(1) {
3291 let curr = &results1[i];
3292 let next = &results1[i + 1];
3293 assert!(
3294 curr.path < next.path
3295 || (curr.path == next.path && curr.span.start_line <= next.span.start_line)
3296 );
3297 }
3298 }
3299
3300 #[test]
3303 fn test_multiple_filters_combined() {
3304 let temp = TempDir::new().unwrap();
3305 let project = temp.path().join("project");
3306 fs::create_dir_all(project.join("src")).unwrap();
3307
3308 fs::write(project.join("src/main.rs"), "fn test() {}\nstruct Test {}").unwrap();
3309 fs::write(project.join("src/lib.rs"), "fn test() {}").unwrap();
3310 fs::write(project.join("test.js"), "function test() {}").unwrap();
3311
3312 let cache = CacheManager::new(&project);
3313 let indexer = Indexer::new(cache, IndexConfig::default());
3314 indexer.index(&project, false).unwrap();
3315
3316 let cache = CacheManager::new(&project);
3317
3318 let engine = QueryEngine::new(cache);
3319
3320 let filter = QueryFilter {
3322 language: Some(Language::Rust),
3323 kind: Some(SymbolKind::Function),
3324 file_pattern: Some("src/main".to_string()),
3325 symbols_mode: true,
3326 ..Default::default()
3327 };
3328 let results = engine.search("test", filter).unwrap();
3329
3330 assert_eq!(results.len(), 1);
3332 assert!(results[0].path.contains("src/main.rs"));
3333 assert_eq!(results[0].kind, SymbolKind::Function);
3334 }
3335
3336 #[test]
3339 fn test_find_symbol_helper() {
3340 let temp = TempDir::new().unwrap();
3341 let project = temp.path().join("project");
3342 fs::create_dir(&project).unwrap();
3343
3344 fs::write(project.join("main.rs"), "fn greet() {}").unwrap();
3345
3346 let cache = CacheManager::new(&project);
3347 let indexer = Indexer::new(cache, IndexConfig::default());
3348 indexer.index(&project, false).unwrap();
3349
3350 let cache = CacheManager::new(&project);
3351
3352 let engine = QueryEngine::new(cache);
3353 let results = engine.find_symbol("greet").unwrap();
3354
3355 assert!(!results.is_empty());
3356 assert_eq!(results[0].kind, SymbolKind::Function);
3357 }
3358
3359 #[test]
3360 fn test_list_by_kind_helper() {
3361 let temp = TempDir::new().unwrap();
3362 let project = temp.path().join("project");
3363 fs::create_dir(&project).unwrap();
3364
3365 fs::write(
3366 project.join("main.rs"),
3367 "struct Point {}\nfn test() {}\nstruct Line {}",
3368 )
3369 .unwrap();
3370
3371 let cache = CacheManager::new(&project);
3372 let indexer = Indexer::new(cache, IndexConfig::default());
3373 indexer.index(&project, false).unwrap();
3374
3375 let cache = CacheManager::new(&project);
3376
3377 let engine = QueryEngine::new(cache);
3378
3379 let filter = QueryFilter {
3381 kind: Some(SymbolKind::Struct),
3382 symbols_mode: true,
3383 use_contains: true, ..Default::default()
3385 };
3386 let results = engine.search("oin", filter).unwrap();
3387
3388 assert!(!results.is_empty(), "Should find at least Point struct");
3390 assert!(results.iter().all(|r| r.kind == SymbolKind::Struct));
3391 assert!(results.iter().any(|r| r.symbol.as_deref() == Some("Point")));
3392 }
3393
3394 #[test]
3397 fn test_search_with_metadata() {
3398 let temp = TempDir::new().unwrap();
3399 let project = temp.path().join("project");
3400 fs::create_dir(&project).unwrap();
3401
3402 fs::write(project.join("main.rs"), "fn test() {}").unwrap();
3403
3404 let cache = CacheManager::new(&project);
3405 let indexer = Indexer::new(cache, IndexConfig::default());
3406 indexer.index(&project, false).unwrap();
3407
3408 let cache = CacheManager::new(&project);
3409
3410 let engine = QueryEngine::new(cache);
3411 let filter = QueryFilter::default();
3412 let response = engine.search_with_metadata("test", filter).unwrap();
3413
3414 assert!(!response.results.is_empty());
3416 }
3418
3419 #[test]
3422 fn test_search_across_languages() {
3423 let temp = TempDir::new().unwrap();
3424 let project = temp.path().join("project");
3425 fs::create_dir(&project).unwrap();
3426
3427 fs::write(project.join("main.rs"), "fn greet() {}").unwrap();
3428 fs::write(project.join("main.ts"), "function greet() {}").unwrap();
3429 fs::write(project.join("main.py"), "def greet(): pass").unwrap();
3430
3431 let cache = CacheManager::new(&project);
3432 let indexer = Indexer::new(cache, IndexConfig::default());
3433 indexer.index(&project, false).unwrap();
3434
3435 let cache = CacheManager::new(&project);
3436
3437 let engine = QueryEngine::new(cache);
3438 let filter = QueryFilter::default();
3439 let results = engine.search("greet", filter).unwrap();
3440
3441 assert!(results.len() >= 3);
3443 assert!(results.iter().any(|r| r.lang == Language::Rust));
3444 assert!(results.iter().any(|r| r.lang == Language::TypeScript));
3445 assert!(results.iter().any(|r| r.lang == Language::Python));
3446 }
3447}