Skip to main content

reflex/query/
mod.rs

1//! Query engine for searching indexed code
2//!
3//! The query engine loads the memory-mapped cache and executes
4//! deterministic searches based on lexical, structural, or symbol patterns.
5
6pub 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
25/// Manages query execution against the index
26pub struct QueryEngine {
27    cache: CacheManager,
28}
29
30impl QueryEngine {
31    /// Create a new query engine with the given cache manager
32    pub fn new(cache: CacheManager) -> Self {
33        Self { cache }
34    }
35
36    /// Load dependencies for search results if requested (legacy - per result)
37    /// Deprecated: Use group_and_load_dependencies for file-level grouping
38    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        // Create dependency index
46        // Note: We need to pass the workspace root, not the cache directory
47        // The cache path is .reflex/, so its parent is the workspace root (.)
48        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        // Load dependencies for each result
57        for result in results {
58            // Normalize path: strip leading "./" if present
59            let normalized_path = result.path.strip_prefix("./").unwrap_or(&result.path);
60
61            // Get file_id from database by path
62            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                    // Get dependencies for this file
66                    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    /// Group search results by file and load dependencies at file level
95    /// Returns file-grouped results with dependencies populated once per file
96    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        // Group results by file path (preserving language from first match)
110        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        // Create dependency index if needed
116        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        // Load ContentReader for extracting context lines
129        let content_path = self.cache.path().join("content.bin");
130        let content_reader_opt = ContentReader::open(&content_path).ok();
131
132        // Convert to FileGroupedResult and load dependencies
133        let mut file_results: Vec<FileGroupedResult> = grouped
134            .into_iter()
135            .map(|(path, file_matches)| {
136                // Capture language from first match (all matches in a file share the same language)
137                let language = file_matches.first().map(|r| r.lang).unwrap_or_default();
138
139                // Load dependencies for this file (once per file, not per result)
140                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                // Get file_id for context extraction
172                // Note: We use ContentReader's get_file_id_by_path() which returns array indices,
173                // not database file_ids (which are AUTO INCREMENT values)
174                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                // Convert SearchResults to MatchResults (strip path and dependencies) and extract context
188                let matches: Vec<MatchResult> = file_matches
189                    .into_iter()
190                    .map(|r| {
191                        // Extract context lines if requested (0 = disabled)
192                        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        // Sort by path for deterministic output
251        file_results.sort_by(|a, b| a.path.cmp(&b.path));
252
253        Ok(file_results)
254    }
255
256    /// Execute a query and return matching results with index metadata
257    ///
258    /// This is the preferred method for programmatic/JSON output as it includes
259    /// index freshness information that AI agents can use to decide whether to re-index.
260    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        // Ensure cache exists
272        if !self.cache.exists() {
273            return Err(crate::errors::ReflexError::IndexNotFound.into());
274        }
275
276        // Validate cache integrity
277        if let Err(e) = self.cache.validate() {
278            // Typed so the MCP layer can auto-rebuild once and retry.
279            return Err(crate::errors::ReflexError::CacheCorrupted(e.to_string()).into());
280        }
281
282        // Get index status and warning (without printing warnings to stderr)
283        let (status, can_trust_results, warning) = self.get_index_status()?;
284
285        // Execute the search
286        let (results, total) = self.search_internal(pattern, filter.clone())?;
287
288        // Build pagination metadata
289        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        // Always use grouped format (group results by file)
299        // Dependencies are loaded only when include_dependencies is true
300        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, // AI instruction is generated by CLI/MCP layer, not here
308            status,
309            can_trust_results,
310            warning,
311            pagination,
312            results: grouped_results,
313        })
314    }
315
316    /// Execute a query and return matching results (legacy method)
317    ///
318    /// This method prints warnings to stderr and returns just the results.
319    /// For programmatic use, prefer `search_with_metadata()`.
320    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        // Ensure cache exists
328        if !self.cache.exists() {
329            return Err(crate::errors::ReflexError::IndexNotFound.into());
330        }
331
332        // Validate cache integrity
333        if let Err(e) = self.cache.validate() {
334            // Typed so the MCP layer can auto-rebuild once and retry.
335            return Err(crate::errors::ReflexError::CacheCorrupted(e.to_string()).into());
336        }
337
338        // Show non-blocking warnings about branch state and staleness
339        self.check_index_freshness(&filter)?;
340
341        // Execute the search (discard total count - legacy method doesn't use it)
342        let (mut results, _total_count) = self.search_internal(pattern, filter.clone())?;
343
344        // Load dependencies if requested
345        self.load_dependencies(&mut results, filter.include_dependencies)?;
346
347        Ok(results)
348    }
349
350    /// Internal search implementation (used by both search methods)
351    /// Returns (results, total_count) where total_count is the count before offset/limit
352    fn search_internal(
353        &self,
354        pattern: &str,
355        filter: QueryFilter,
356    ) -> Result<(Vec<SearchResult>, usize)> {
357        use std::time::{Duration, Instant};
358
359        // Start timeout timer if configured
360        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        // KEYWORD DETECTION (early): Check if this is a keyword query that should scan ALL files
368        // When a user searches for a language keyword (like "class", "function") with --symbols or --kind,
369        // we interpret it as "list all symbols of that type" and should scan ALL files,
370        // not just the first 100 candidates from trigram search.
371        //
372        // Requirements for keyword query mode:
373        // 1. Symbol mode active (--symbols or --kind)
374        // 2. Pattern matches a keyword in ANY supported language
375        //
376        // Note: --lang is optional. If specified, language filtering happens naturally in Phase 2/3.
377        // Empty pattern in symbol mode means "list all symbols of the requested kind" —
378        // treat it like a keyword query so we scan all files instead of failing the
379        // broad-query guard or returning zero trigram matches.
380        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        // KEYWORD-TO-KIND MAPPING: If user searches for a keyword without --kind, infer the kind
387        // Example: "class" → SymbolKind::Class, "function" → SymbolKind::Function
388        // This ensures keyword queries return only the relevant symbol type
389        let mut filter = filter.clone(); // Clone so we can modify it
390        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        // EARLY BROAD QUERY DETECTION (Index Size Check)
403        // This check happens BEFORE the expensive trigram search to prevent hangs on large indexes
404        // For very large codebases (like Linux kernel with 62K files), even valid 3-char trigrams
405        // like "get" can take 10-30+ seconds to search. This early check prevents that hang.
406        //
407        // Criteria for early blocking:
408        // 1. Large index (> 20,000 files) AND
409        // 2. Short pattern (< 4 chars) AND
410        // 3. Not using regex (regex has its own trigram extraction) AND
411        // 4. Not a keyword query (keywords are intentionally broad) AND
412        // 5. Not forced by --force flag
413        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            // Thresholds for early blocking:
419            // - Large index: 20,000+ files (approximately where performance degrades significantly)
420            // - Short pattern: < 4 chars (3-char trigrams are borderline, < 4 catches edge cases)
421            // Test overrides allow reducing thresholds for integration tests without creating 20K+ files
422            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        // PHASE 1: Get initial candidates (choose search strategy)
455        let mut results = if is_keyword_query {
456            // KEYWORD QUERY MODE: Scan all files (or files of target language if --lang specified)
457            // This ensures we find ALL classes/functions/etc, not just those in the first 100 trigram matches
458            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            // Regex pattern search with trigram optimization
473            self.get_regex_candidates(
474                pattern,
475                timeout.as_ref(),
476                &start_time,
477                filter.suppress_output,
478            )?
479        } else {
480            // Standard trigram-based full-text search
481            self.get_trigram_candidates(pattern, &filter)?
482        };
483
484        // EARLY LANGUAGE FILTER: Apply language filtering BEFORE broad query check
485        // This ensures we only parse files matching the language filter in Phase 2
486        // Critical for non-keyword queries to work correctly with accurate candidate counts
487        //
488        // Skip for keyword queries - those candidates are already pre-filtered by language
489        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        // EARLY GLOB PATTERN FILTER: Apply glob/exclude filtering BEFORE broad query check
501        // This ensures candidate count reflects actual files that will be parsed
502        // Critical for queries like: rfx query "index" --symbols --glob "src/**/*.rs"
503        if !filter.glob_patterns.is_empty() || !filter.exclude_patterns.is_empty() {
504            use globset::{Glob, GlobSetBuilder};
505
506            // Build include matcher (if patterns specified)
507            let include_matcher = if !filter.glob_patterns.is_empty() {
508                let mut builder = GlobSetBuilder::new();
509                for pattern in &filter.glob_patterns {
510                    // Normalize pattern to ensure LLM-generated patterns work correctly
511                    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            // Build exclude matcher (if patterns specified)
533            let exclude_matcher = if !filter.exclude_patterns.is_empty() {
534                let mut builder = GlobSetBuilder::new();
535                for pattern in &filter.exclude_patterns {
536                    // Normalize pattern to ensure LLM-generated patterns work correctly
537                    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            // Apply filters
559            let before_count = results.len();
560            results.retain(|r| {
561                // If include patterns specified, path must match at least one
562                let included = if let Some(ref matcher) = include_matcher {
563                    matcher.is_match(&r.path)
564                } else {
565                    true // No include patterns = include all
566                };
567
568                // If exclude patterns specified, path must NOT match any
569                let excluded = if let Some(ref matcher) = exclude_matcher {
570                    matcher.is_match(&r.path)
571                } else {
572                    false // No exclude patterns = exclude none
573                };
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        // Check timeout after Phase 1
585        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        // BROAD QUERY DETECTION: Check if query is too expensive BEFORE parsing
604        // This protects LLM users from accidentally running expensive queries that flood context windows
605        if !filter.force {
606            let candidate_count = results.len();
607            let pattern_len = pattern.chars().count();
608
609            // Condition 1: Pattern too short (< 3 chars can't use trigram optimization efficiently)
610            // Exception: Allow short keyword queries (e.g., "fn", "if") since they scan all language files
611            let is_short_pattern = pattern_len < 3 && !filter.use_regex && !is_keyword_query;
612
613            // Condition 2: AST query without glob restriction on large codebases
614            // Allow on small codebases (< 100 files) but require glob for larger ones
615            let is_broad_ast =
616                filter.use_ast && filter.glob_patterns.is_empty() && candidate_count >= 100;
617
618            // Condition 3: Query-type-aware threshold for symbol/AST parsing
619            // Different thresholds based on actual performance characteristics:
620            // - AST without glob: 100 files (allow small codebases, block large ones)
621            // - AST with glob: 10,000 files (~5 seconds max)
622            // - Keyword queries: 20,000 files (~3 seconds max) - scan all files of language
623            // - Trigram-filtered symbols: 50,000 files (~5 seconds max) - very fast due to trigram filtering
624            let threshold = if filter.use_ast && filter.glob_patterns.is_empty() {
625                100 // AST without glob - allow small codebases
626            } else if filter.use_ast {
627                10_000 // AST with glob restriction
628            } else if is_keyword_query {
629                20_000 // Keyword queries (e.g., "class", "function")
630            } else {
631                50_000 // Trigram-filtered symbol queries
632            };
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                // Build the command snippet showing current flags
691                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        // DETERMINISTIC SORTING: Sort candidates early for deterministic results
730        // This ensures results are always returned in the same order
731        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            // Warn if many candidates need parsing (helps users refine queries)
739            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        // PHASE 2: Enrich with symbol information or AST pattern matching (if needed)
754        if filter.use_ast {
755            // AST pattern matching: Execute Tree-sitter query on candidate files
756            results = self.enrich_with_ast(results, pattern, filter.language)?;
757        } else if filter.symbols_mode || filter.kind.is_some() {
758            // Symbol enrichment: Parse candidate files and extract symbol definitions
759            results = self.enrich_with_symbols(results, pattern, &filter)?;
760        }
761
762        // PHASE 3: Apply post-enrichment filters
763        // Note: Language and glob filters are applied in Phase 1 (before broad query check)
764        // Only kind, file_pattern, and exact filters are applied here
765
766        // Deduplicate symbols: the same source location can be emitted as both
767        // Function and Method by some parsers.  Keep the first hit for each
768        // (path, start_line, symbol_name) triple so --kind function doesn't
769        // return the same definition twice.
770        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        // Apply kind filter (only relevant for symbol searches)
776        // Special case: --kind function also includes methods (methods are functions in classes)
777        if let Some(ref kind) = filter.kind {
778            results.retain(|r| {
779                if matches!(kind, SymbolKind::Function) {
780                    // When searching for functions, also include methods
781                    matches!(r.kind, SymbolKind::Function | SymbolKind::Method)
782                } else {
783                    r.kind == *kind
784                }
785            });
786        }
787
788        // Apply file path filter (substring match)
789        if let Some(ref file_pattern) = filter.file_pattern {
790            results.retain(|r| r.path.contains(file_pattern));
791        }
792
793        // Apply exact name filter (only for symbol searches)
794        if filter.exact && filter.symbols_mode {
795            results.retain(|r| r.symbol.as_deref() == Some(pattern));
796        }
797
798        // Expand symbol bodies if requested
799        // Works for both symbol-mode and regex searches (if regex matched a symbol definition)
800        if filter.expand {
801            // Load content store to fetch full symbol bodies
802            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                    // Only expand if the result has a meaningful span (not just a single line)
806                    if result.span.start_line < result.span.end_line {
807                        // Find the file_id for this result's path
808                        if let Some(file_id) = Self::find_file_id(&content_reader, &result.path) {
809                            // Fetch the full span content
810                            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        // Step 4: Deduplicate by path if paths-only mode
827        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        // Step 5: Sort results deterministically (by path, then line number)
834        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        // Capture total count AFTER all filtering but BEFORE pagination (offset/limit)
841        // This is the total number of results the user can paginate through
842        let total_count = results.len();
843
844        // Step 5.5: Apply offset (pagination)
845        if let Some(offset) = filter.offset {
846            if offset < results.len() {
847                results = results.into_iter().skip(offset).collect();
848            } else {
849                // Offset beyond results - return empty
850                results.clear();
851            }
852        }
853
854        // Step 6: Apply limit
855        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    /// Search for symbols by exact name match
869    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    /// Search using a Tree-sitter AST pattern
878    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    /// Execute AST query on all indexed files (no trigram filtering)
889    ///
890    /// WARNING: This method scans the entire codebase (500ms-2s+).
891    /// In 95% of cases, use --symbols instead which is 10-100x faster.
892    ///
893    /// # Algorithm
894    /// 1. Get all indexed files for the specified language
895    /// 2. Apply glob/exclude filters to reduce file set
896    /// 3. Load file contents for all matching files
897    /// 4. Execute AST query pattern using Tree-sitter
898    /// 5. Apply remaining filters and return results
899    ///
900    /// # Performance
901    /// - Parses entire codebase (not just trigram candidates)
902    /// - Expected: 500ms-2s for medium codebases, 2-10s for large codebases
903    /// - Use --glob to limit scope for better performance
904    ///
905    /// # Requirements
906    /// - Language must be specified (AST queries are language-specific)
907    /// - AST pattern must be valid S-expression syntax
908    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        // Require language for AST queries
920        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        // Ensure cache exists
927        if !self.cache.exists() {
928            return Err(crate::errors::ReflexError::IndexNotFound.into());
929        }
930
931        // Show non-blocking warnings about branch state and staleness
932        self.check_index_freshness(&filter)?;
933
934        // Load content store
935        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        // Build glob matchers ONCE before file iteration (performance optimization)
940        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                // Normalize pattern to ensure LLM-generated patterns work correctly
946                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                // Normalize pattern to ensure LLM-generated patterns work correctly
960                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        // Get all files matching the language and glob filters
971        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            // Detect language from file extension
980            let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
981            let detected_lang = Language::from_extension(ext);
982
983            // Filter by language
984            if detected_lang != lang {
985                continue;
986            }
987
988            let file_path_str = file_path.to_string_lossy().to_string();
989
990            // Apply glob/exclude filters BEFORE loading content (performance optimization)
991            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            // Create a dummy candidate for this file (AST query will replace it)
1003            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        // BROAD QUERY DETECTION: Block large AST queries without glob restriction
1024        // Allow small codebases (<100 files) but require --glob for larger ones
1025        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        // Execute the AST query on all candidate files
1061        // This will load file contents and parse them with tree-sitter
1062        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        // Apply remaining filters (same as search_internal Phase 3)
1067
1068        // Apply kind filter
1069        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        // Note: exact filter doesn't make sense for AST queries (pattern is S-expression, not symbol name)
1080
1081        // Expand symbol bodies if requested
1082        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        // Deduplicate by path if paths-only mode
1104        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        // Sort results deterministically
1111        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        // Apply offset (pagination)
1118        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        // Apply limit
1127        if let Some(limit) = filter.limit {
1128            results.truncate(limit);
1129        }
1130
1131        log::info!("AST query returned {} results", results.len());
1132
1133        // Load dependencies if requested
1134        self.load_dependencies(&mut results, filter.include_dependencies)?;
1135
1136        Ok(results)
1137    }
1138
1139    /// Search using AST pattern with separate text pattern for trigram filtering
1140    ///
1141    /// This allows efficient AST queries by:
1142    /// 1. Using text_pattern for Phase 1 trigram filtering (narrows to candidate files)
1143    /// 2. Using ast_pattern for Phase 2 AST matching (structure-aware filtering)
1144    ///
1145    /// # Example
1146    /// ```ignore
1147    /// // Find async functions: trigram search for "fn ", AST match for function_item
1148    /// engine.search_ast_with_text_filter("fn ", "(function_item (async))", filter)?;
1149    /// ```
1150    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        // Ensure cache exists
1164        if !self.cache.exists() {
1165            return Err(crate::errors::ReflexError::IndexNotFound.into());
1166        }
1167
1168        // Show non-blocking warnings about branch state and staleness
1169        self.check_index_freshness(&filter)?;
1170
1171        // Start timeout timer if configured
1172        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        // PHASE 1: Get initial candidates using text pattern (trigram search)
1181        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        // PHASE 2: Execute AST query on candidates
1195        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        // PHASE 3: Apply filters
1200        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        // Apply glob pattern filters (same logic as in search_internal)
1219        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                    // Normalize pattern to ensure LLM-generated patterns work correctly
1226                    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                    // Normalize pattern to ensure LLM-generated patterns work correctly
1240                    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        // Expand symbol bodies if requested
1264        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        // Sort results deterministically
1286        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        // Apply offset (pagination)
1293        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        // Apply limit
1302        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    /// List all symbols of a specific kind
1312    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    /// Enrich text match candidates with symbol information by parsing files
1323    ///
1324    /// Takes a list of text match candidates and extracts symbol information at those locations.
1325    ///
1326    /// # Algorithm
1327    /// 1. Group candidates by file_id for efficient processing
1328    /// 2. Parse each file with tree-sitter to extract ALL symbols
1329    /// 3. Filter symbols based on matching strategy:
1330    ///    - If use_regex=true: Extract symbols whose line spans overlap with candidate locations
1331    ///    - If use_contains=true: Filter symbols by substring match on symbol name
1332    ///    - Default: Filter symbols by exact name match
1333    /// 4. Return filtered symbol results
1334    ///
1335    /// # Performance
1336    /// Only parses files that have text matches, so typically 10-100 files
1337    /// instead of the entire codebase (62K+ files).
1338    ///
1339    /// # Optimizations
1340    /// 1. Language filtering: Skips files with unsupported languages (no parsers)
1341    /// 2. Parallel processing: Uses Rayon to parse files concurrently across CPU cores
1342    fn enrich_with_symbols(
1343        &self,
1344        candidates: Vec<SearchResult>,
1345        pattern: &str,
1346        filter: &QueryFilter,
1347    ) -> Result<Vec<SearchResult>> {
1348        // Load content store for file reading
1349        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        // Load trigram index for file path lookups
1354        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        // Open symbol cache for reading cached symbols
1362        let symbol_cache = crate::symbol_cache::SymbolCache::open(self.cache.path())
1363            .context("Failed to open symbol cache")?;
1364
1365        // Load file hashes for current branch for cache lookups
1366        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        // Group candidates by file, filtering out unsupported languages
1380        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            // Skip files with unsupported languages (no parser available)
1386            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        // Warn if pattern is very broad (may take time to parse all files)
1405        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        // Convert to vec for parallel processing
1413        let mut files_to_process: Vec<String> = files_by_path.keys().cloned().collect();
1414
1415        // PHASE 2a: Line-based pre-filtering (skip files where ALL matches are in comments/strings)
1416        // This reduces tree-sitter parsing workload by 2-5x for most queries
1417        let mut files_to_skip: std::collections::HashSet<String> = std::collections::HashSet::new();
1418
1419        for file_path in &files_to_process {
1420            // Get the language for this file
1421            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            // Get line filter for this language (if available)
1428            if let Some(line_filter) = crate::line_filter::get_filter(lang) {
1429                // Find file_id for this path
1430                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                // Load file content
1437                let content = match content_reader.get_file_content(file_id) {
1438                    Ok(c) => c,
1439                    Err(_) => continue,
1440                };
1441
1442                // Check if ALL pattern occurrences are in comments/strings
1443                let mut all_in_non_code = true;
1444                for line in content.lines() {
1445                    // Find all occurrences of the pattern in this line
1446                    let mut search_start = 0;
1447                    while let Some(pos) = line[search_start..].find(pattern) {
1448                        let absolute_pos = search_start + pos;
1449
1450                        // Check if this occurrence is in code (not comment/string)
1451                        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                            // Found at least one occurrence in actual code
1456                            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 occurrences are in comments/strings, skip this file
1469                if all_in_non_code {
1470                    // Double-check: make sure there was at least one occurrence
1471                    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        // Filter out files we're skipping
1483        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        // Configure thread pool for parallel processing (use 80% of available cores, capped at 8)
1492        let num_threads = {
1493            let available_cores = std::thread::available_parallelism()
1494                .map(|n| n.get())
1495                .unwrap_or(4);
1496            // Use 80% of available cores (minimum 1, maximum 8) to avoid locking the system
1497            // Cap at 8 to prevent diminishing returns from cache contention on high-core systems
1498            ((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        // Build a custom thread pool with limited threads
1510        let pool = rayon::ThreadPoolBuilder::new()
1511            .num_threads(num_threads)
1512            .build()
1513            .context("Failed to create thread pool for symbol extraction")?;
1514
1515        // OPTIMIZATION: Batch read all cached symbols in ONE database transaction
1516        // This is 10-30x faster than calling get() individually for each file
1517
1518        // Step 1: Collect file paths that have hashes
1519        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        // Step 2: Batch lookup file_ids for all paths
1526        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        // Step 3: Build (file_id, hash, path) tuples for batch_get_with_kind
1532        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        // Step 4: Batch read symbols with kind filtering (uses junction table + integer joins)
1542        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        // Step 5: Separate files into cached vs need-to-parse
1547        let mut cached_symbols: HashMap<String, Vec<SearchResult>> = HashMap::new();
1548        let mut files_needing_parse: Vec<String> = Vec::new();
1549
1550        // Build path lookup from file_id
1551        let id_to_path: HashMap<i64, String> = file_id_map
1552            .iter()
1553            .map(|(path, id)| (*id, path.clone()))
1554            .collect();
1555
1556        // Process cached results
1557        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        // Files with hashes but not in cache results need parsing
1564        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        // Add files without hashes to parse list
1571        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        // Parse files in parallel using custom thread pool (only cache misses)
1584        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                    // Find file_id for this path
1591                    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                    // Detect language
1612                    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                    // Parse file to extract symbols
1619                    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                    // Cache the parsed symbols (ignore errors - caching is best-effort)
1631                    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        // Combine cached and parsed symbols
1643        let mut all_symbols: Vec<SearchResult> = Vec::new();
1644
1645        // Add all cached symbols
1646        for symbols in cached_symbols.values() {
1647            all_symbols.extend_from_slice(symbols);
1648        }
1649
1650        // Add all parsed symbols
1651        all_symbols.extend(parsed_symbols);
1652
1653        // KEYWORD DETECTION: Check if pattern is a language keyword (e.g., "class", "function")
1654        // If it matches a keyword AND symbols_mode is true, interpret as "list all symbols of that type"
1655        // rather than looking for a symbol literally named "class" or "function"
1656        //
1657        // IMPORTANT: Only check keywords for languages that will pass Phase 3 filtering.
1658        // If a language filter is specified, only check that language's keywords.
1659        // Otherwise, check all languages present in the symbol results.
1660        let is_keyword_query = {
1661            // Determine which language to check keywords for
1662            let lang_to_check = if let Some(lang) = filter.language {
1663                // Language filter specified - check that language only
1664                // This ensures keyword detection aligns with Phase 3 language filtering
1665                vec![lang]
1666            } else {
1667                // No language filter - check all languages that appear in the actual symbols
1668                // (not candidates, but the parsed symbols that made it through)
1669                // This handles mixed-language codebases correctly
1670                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))); // Deterministic ordering
1673                langs.dedup(); // Remove duplicates after sorting
1674                langs
1675            };
1676
1677            // Check if pattern matches a keyword in any of the relevant languages
1678            lang_to_check
1679                .iter()
1680                .any(|lang| ParserFactory::get_keywords(*lang).contains(&pattern))
1681        };
1682
1683        // If pattern is a keyword (like "class" or "function"), skip name-based filtering
1684        // and return all symbols (kind filtering happens in Phase 3)
1685        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            // For regex queries, candidates already matched content via regex in Phase 1.
1693            // Extract symbols whose line spans overlap with the candidate locations.
1694            // This ensures symbols are found at the locations where the regex matched.
1695
1696            // Build a map of (file_path, line_no) from candidates
1697            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            // Filter symbols whose spans overlap with candidate lines
1709            all_symbols
1710                .into_iter()
1711                .filter(|sym| {
1712                    if let Some(lines) = candidate_lines.get(&sym.path) {
1713                        // Check if symbol's line span overlaps with any candidate line
1714                        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            // Substring match (opt-in with --contains)
1725            all_symbols
1726                .into_iter()
1727                .filter(|sym| sym.symbol.as_deref().is_some_and(|s| s.contains(pattern)))
1728                .collect()
1729        } else {
1730            // Exact match (default)
1731            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    /// Enrich text match candidates with AST pattern matching
1747    ///
1748    /// Takes a list of text match candidates and executes a Tree-sitter AST query
1749    /// on the candidate files, returning only matches that satisfy the AST pattern.
1750    ///
1751    /// # Algorithm
1752    /// 1. Extract unique file paths from candidates
1753    /// 2. Load file contents for each candidate file
1754    /// 3. Execute AST query pattern using Tree-sitter
1755    /// 4. Return AST matches
1756    ///
1757    /// # Performance
1758    /// Only parses files that have text matches, so typically 10-100 files
1759    /// instead of the entire codebase (62K+ files).
1760    ///
1761    /// # Requirements
1762    /// - Language must be specified (AST queries are language-specific)
1763    /// - AST pattern must be valid S-expression syntax
1764    fn enrich_with_ast(
1765        &self,
1766        candidates: Vec<SearchResult>,
1767        ast_pattern: &str,
1768        language: Option<Language>,
1769    ) -> Result<Vec<SearchResult>> {
1770        // Require language for AST queries
1771        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        // Load content store for file reading
1776        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        // Load trigram index for file path lookups
1781        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        // Collect unique file paths from candidates and load their contents
1789        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            // Find file_id for this path
1798            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            // Load file content
1811            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        // Execute AST query using the ast_query module
1829        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    /// Helper to find file_id by path string
1842    fn find_file_id_by_path(
1843        content_reader: &ContentReader,
1844        trigram_index: &TrigramIndex,
1845        target_path: &str,
1846    ) -> Option<u32> {
1847        // Try trigram index first (faster)
1848        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        // Fallback to content reader
1857        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    /// Map keyword patterns to SymbolKind for auto-inference
1869    ///
1870    /// When users search for keywords like "class" or "function" with --symbols,
1871    /// automatically infer the kind filter to return only symbols of that type.
1872    ///
1873    /// This makes keyword queries more intuitive: searching for "class" returns
1874    /// only classes, not all symbols.
1875    fn keyword_to_kind(keyword: &str) -> Option<SymbolKind> {
1876        filter::keyword_to_kind(keyword)
1877    }
1878
1879    /// Get all files matching the language filter (for keyword queries)
1880    ///
1881    /// This method bypasses trigram search and returns ALL files of the specified language.
1882    /// Used for keyword queries like "list all classes" where we need complete coverage,
1883    /// not just the first 100 candidates from a trigram search.
1884    ///
1885    /// Similar to `search_ast_all_files()` but works for symbol queries instead of AST queries.
1886    fn get_all_language_files(&self, filter: &QueryFilter) -> Result<Vec<SearchResult>> {
1887        // Language filter is optional - if not specified, scan all files
1888        // If specified, only scan files of that language
1889
1890        // Load content store
1891        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        // Build glob matchers if specified (for filtering)
1896        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        // Scan all files and filter by language + glob patterns
1925        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            // Detect language from file extension
1934            let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
1935            let detected_lang = Language::from_extension(ext);
1936
1937            // Filter by language (if specified)
1938            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            // Apply glob/exclude filters
1947            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            // Apply file path filter if specified
1959            if let Some(ref file_pattern) = filter.file_pattern
1960                && !file_path_str.contains(file_pattern)
1961            {
1962                continue;
1963            }
1964
1965            // Create a dummy candidate for this file
1966            // Phase 2 (symbol enrichment) will parse it and extract actual symbols
1967            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    /// Get candidate results using trigram-based full-text search
1998    fn get_trigram_candidates(
1999        &self,
2000        pattern: &str,
2001        filter: &QueryFilter,
2002    ) -> Result<Vec<SearchResult>> {
2003        // Load content store
2004        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        // Patterns shorter than 3 chars have no trigrams, so the trigram index always
2009        // returns empty.  Fall back to a linear scan of the content store so that
2010        // --force (which bypasses the broad-query guard) still produces real results.
2011        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        // Load trigram index from disk (or rebuild if missing)
2021        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        // Search using trigrams
2044        let candidates = trigram_index.search(pattern);
2045        log::debug!(
2046            "Found {} candidate locations from trigram search",
2047            candidates.len()
2048        );
2049
2050        // Clone pattern to owned String for thread safety
2051        let pattern_owned = pattern.to_string();
2052
2053        // Compile regex once if in regex mode (before parallel processing for efficiency)
2054        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        // Group candidates by file for efficient processing
2067        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        // Process files in parallel using rayon
2080        use rayon::prelude::*;
2081
2082        let results: Vec<SearchResult> = candidates_by_file
2083            .par_iter()
2084            .flat_map(|(file_id, locations)| {
2085                // Get file metadata
2086                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                // Detect language once per file
2099                let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
2100                let lang = Language::from_extension(ext);
2101
2102                // Split content into lines once
2103                let lines: Vec<&str> = content.lines().collect();
2104
2105                // Use a HashSet to deduplicate results by line number
2106                let mut seen_lines: std::collections::HashSet<usize> =
2107                    std::collections::HashSet::new();
2108                let mut file_results = Vec::new();
2109
2110                // Only check the specific lines indicated by trigram posting lists
2111                for loc in locations {
2112                    let line_no = loc.line_no as usize;
2113
2114                    // Skip if we've already processed this line
2115                    if seen_lines.contains(&line_no) {
2116                        continue;
2117                    }
2118
2119                    // Bounds check
2120                    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                    // Apply matching strategy based on filter mode:
2132                    // - Default: Word-boundary matching (restrictive - finds whole identifiers)
2133                    // - --contains: Substring matching (expansive - finds pattern anywhere)
2134                    // - --regex: Actual regex matching (controlled by pattern itself)
2135                    let line_matches = if filter.use_regex {
2136                        // Regex matching - use pre-compiled regex for efficiency
2137                        // The regex was compiled once outside the parallel loop
2138                        compiled_regex
2139                            .as_ref()
2140                            .map(|re| re.is_match(line))
2141                            .unwrap_or(false)
2142                    } else if filter.use_contains {
2143                        // Substring matching (expansive)
2144                        line.contains(&pattern_owned)
2145                    } else {
2146                        // Word-boundary matching (restrictive, default)
2147                        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                    // Create a text match result (no symbol lookup for performance)
2157                    file_results.push(SearchResult {
2158                        path: file_path_str.clone(),
2159                        lang,
2160                        kind: SymbolKind::Unknown("text_match".to_string()),
2161                        symbol: None, // No symbol name for text matches (avoid duplication)
2162                        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    /// Linear scan fallback for patterns shorter than 3 characters.
2179    ///
2180    /// The trigram index requires 3-char n-grams; patterns like "fn" or "i" yield
2181    /// zero trigrams and therefore zero results.  This method scans every file in
2182    /// the content store directly using the same matching logic (word-boundary,
2183    /// contains, or regex) so short-pattern queries always return real results.
2184    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    /// Get candidate results using regex patterns with trigram optimization
2274    ///
2275    /// # Algorithm
2276    ///
2277    /// 1. Extract literal sequences from the regex pattern (≥3 chars)
2278    /// 2. If literals found: search for files containing ANY of the literals (UNION)
2279    /// 3. If no literals: fall back to full content scan
2280    /// 4. Compile regex and verify matches in candidate files
2281    /// 5. Return matching results with context
2282    ///
2283    /// # File Selection Strategy
2284    ///
2285    /// Uses UNION of files containing any literal (conservative approach):
2286    /// - For alternation patterns `(a|b)`: Correctly searches files with a OR b
2287    /// - For sequential patterns `a.*b`: Searches files with a OR b (may include extra files)
2288    /// - Trade-off: Ensures correctness at the cost of scanning 2-3x more files for sequential patterns
2289    /// - Performance impact is minimal due to memory-mapped I/O (<5ms overhead typically)
2290    ///
2291    /// # Performance
2292    ///
2293    /// - Best case (pattern with literals): <20ms (trigram optimization)
2294    /// - Typical case (alternation/sequential): 5-15ms on small codebases (<100 files)
2295    /// - Worst case (no literals like `.*`): ~100ms (full scan)
2296    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        // Step 1: Compile the regex
2304        let regex =
2305            Regex::new(pattern).with_context(|| format!("Invalid regex pattern: {}", pattern))?;
2306
2307        // Check timeout before expensive operations
2308        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        // Step 2: Extract trigrams from regex
2318        let trigrams = extract_trigrams_from_regex(pattern);
2319
2320        // Load content store
2321        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            // No trigrams - fall back to full scan
2329            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            // Scan all files
2337            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(&regex, file_path, content, &mut results)?;
2344            }
2345        } else {
2346            // Use trigrams to narrow down candidates
2347            log::debug!(
2348                "Using {} trigrams to narrow regex search candidates",
2349                trigrams.len()
2350            );
2351
2352            // Load trigram index
2353            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            // Extract the literal sequences from the regex pattern
2361            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                // Fall back to full scan
2369                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(&regex, file_path, content, &mut results)?;
2375                }
2376            } else {
2377                // Search for each literal sequence and union the results
2378                // This ensures we find matches for ANY literal (important for alternation patterns like (a|b))
2379                // Trade-off: May scan more files than necessary for sequential patterns (a.*b),
2380                // but ensures correctness for all regex patterns
2381                use std::collections::HashSet;
2382                let mut candidate_files: HashSet<u32> = HashSet::new();
2383
2384                for literal in &literals {
2385                    // Search for this literal in the trigram index
2386                    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                    // Union with existing candidate files (not intersection)
2392                    // This ensures we search files containing ANY of the literals
2393                    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                // Verify regex matches in candidate files only
2403                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(&regex, 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    /// Find all regex matches in a single file
2423    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        // Detect language from file extension
2433        let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
2434        let lang = Language::from_extension(ext);
2435
2436        // Find all regex matches line by line
2437        for (line_idx, line) in content.lines().enumerate() {
2438            if regex.is_match(line) {
2439                let line_no = line_idx + 1;
2440
2441                // Create text match result
2442                // Note: We don't extract symbol names from regex matches because:
2443                // 1. Regex might match partial identifiers (e.g., "UserController" in "ListUserController")
2444                // 2. Regex might match across language-specific delimiters (namespaces, scopes, etc.)
2445                // 3. Accurate symbol extraction requires tree-sitter parsing (expensive)
2446                // The user can see the full context in the 'preview' field
2447                results.push(SearchResult {
2448                    path: file_path_str.clone(),
2449                    lang,
2450                    kind: SymbolKind::Unknown("regex_match".to_string()),
2451                    symbol: None, // No symbol name for regex matches
2452                    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    /// Get index status for programmatic use (doesn't print warnings)
2482    ///
2483    /// Returns (status, can_trust_results, warning) tuple for JSON output.
2484    /// This is optimized for AI agents to detect staleness and auto-reindex.
2485    pub fn get_index_status(&self) -> Result<(IndexStatus, bool, Option<IndexWarning>)> {
2486        let root = self.cache.workspace_root();
2487
2488        // Check git state if in a git repo
2489        if crate::git::is_git_repo(&root)
2490            && let Ok(current_branch) = crate::git::get_current_branch(&root)
2491        {
2492            // Check if we're on a different branch than what was indexed
2493            if !self.cache.branch_exists(&current_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            // Branch exists - check if commit changed
2509            if let (Ok(current_commit), Ok(branch_info)) = (
2510                crate::git::get_current_commit(&root),
2511                self.cache.get_branch_info(&current_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                            &current_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 commits match, do a quick file freshness check
2533                if let Ok(branch_files) = self.cache.get_branch_files(&current_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                                // File modified after indexing - likely stale
2553                                // Note: We skip hash verification for performance (mtime check is sufficient)
2554                                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                // All checks passed - index is fresh
2576                return Ok((IndexStatus::Fresh, true, None));
2577            }
2578        }
2579
2580        // Not in a git repo or couldn't get git info - assume fresh
2581        Ok((IndexStatus::Fresh, true, None))
2582    }
2583
2584    /// Check index freshness and show non-blocking warnings
2585    ///
2586    /// This performs lightweight checks to warn users if their index might be stale:
2587    /// 1. Branch mismatch: indexed different branch
2588    /// 2. Commit changed: HEAD moved since indexing
2589    /// 3. File changes: quick mtime check on sample of files (if available)
2590    fn check_index_freshness(&self, filter: &QueryFilter) -> Result<()> {
2591        let root = self.cache.workspace_root();
2592
2593        // Check git state if in a git repo
2594        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                // Check if we're on a different branch than what was indexed
2606                if !self.cache.branch_exists(&current_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                // Branch exists - check if commit changed
2617                if let (Ok(current_commit), Ok(branch_info)) = (
2618                    crate::git::get_current_commit(&root),
2619                    self.cache.get_branch_info(&current_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                                &current_commit[..7]
2627                            ));
2628                        }
2629                        return Ok(());
2630                    }
2631
2632                    // If commits match, do a quick file freshness check
2633                    // Sample up to 10 files to check for modifications (cheap mtime check)
2634                    if let Ok(branch_files) = self.cache.get_branch_files(&current_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                            // Check if file exists and has been modified (mtime/size heuristic)
2644                            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 modified after indexing, it might be stale
2655                                if file_time > indexed_time {
2656                                    // File modified after indexing - likely stale
2657                                    // Note: We skip hash verification for performance (mtime check is sufficient)
2658                                    // This may cause false positives if files were touched without changes,
2659                                    // but the warning is non-blocking and vastly better than slow queries
2660                                    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/// Generate AI instruction based on query results
2681///
2682/// Provides context-aware guidance to AI agents on how to handle search results.
2683/// Uses priority-based logic to determine the most relevant instruction.
2684#[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    // Priority 1: No results
2698    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    // Priority 2: Query too broad (500+ results)
2706    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    // Priority 3: Paginated results
2714    //
2715    // REF-191: for autonomous find-all tasks this instruction must NOT tell the
2716    // agent to stop and ask the user — there is no user in an agent loop, and a
2717    // partial answer to "find every occurrence" is wrong. Instruct decisive
2718    // continuation: fetch the remaining page(s) via offset, or probe the total
2719    // cheaply with mode="count" first.
2720    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    // Priority 4: Single precise result (symbols mode)
2731    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    // Priority 5: Few precise results (symbols mode)
2738    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    // Priority 6: Many results (101-500)
2746    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    // Priority 7: Full-text mode with many results (suggest symbols mode)
2754    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    // Priority 8: Paths-only mode
2762    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    // Priority 9: AST query results
2770    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    // Priority 10: Regex with many results
2778    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    // Priority 11: Language filter with few results
2786    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    // Priority 12: Glob filter with few results
2794    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    // Priority 13: Exact mode with few results
2802    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    // Normal case (11-100 results, no special conditions) - no instruction
2810    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    // ==================== Basic Tests ====================
2822
2823    #[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        // Test that symbols_mode works as expected
2835        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        // Test that kind implies symbols_mode (handled in CLI layer)
2845        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    // ==================== Search Mode Tests ====================
2854
2855    #[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        // Create test files
2862        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        // Index the project
2870        let cache = CacheManager::new(&project);
2871        let indexer = Indexer::new(cache, IndexConfig::default());
2872        indexer.index(&project, false).unwrap();
2873
2874        // Search for "hello"
2875        let cache = CacheManager::new(&project);
2876        let engine = QueryEngine::new(cache);
2877        let filter = QueryFilter::default(); // full-text mode
2878        let results = engine.search("hello", filter).unwrap();
2879
2880        // Should find both occurrences (println and function name)
2881        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        // Create test file with function definition and call
2893        fs::write(
2894            project.join("main.rs"),
2895            "fn greet() {}\nfn main() {\n    greet();\n}",
2896        )
2897        .unwrap();
2898
2899        // Index
2900        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        // Symbol search (definitions only)
2907        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        // Should find only the definition, not the call
2915        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        // Should match test1 and test2 but not other
2945        assert_eq!(results.len(), 2);
2946        assert!(results.iter().all(|r| r.preview.contains("test")));
2947    }
2948
2949    // ==================== Filter Tests ====================
2950
2951    #[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        // Filter to Rust only
2969        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        // Filter to functions only (includes methods)
3000        let filter = QueryFilter {
3001            symbols_mode: true,
3002            kind: Some(SymbolKind::Function),
3003            use_contains: true, // "mai" is substring of "main"
3004            ..Default::default()
3005        };
3006        // Search for "mai" which should match "main" (tri gram pattern will def be in index)
3007        let results = engine.search("mai", filter).unwrap();
3008
3009        // Should find main function
3010        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        // Filter to src/ only
3036        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        // Create file with many matches
3053        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        // Limit to 5 results
3068        let filter = QueryFilter {
3069            limit: Some(5),
3070            use_contains: true, // "test" is substring of "test0", "test1", etc.
3071            ..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        // Exact match for "test"
3099        let filter = QueryFilter {
3100            symbols_mode: true,
3101            exact: true,
3102            ..Default::default()
3103        };
3104        let results = engine.search("test", filter).unwrap();
3105
3106        // Should only match exactly "test", not "test_helper" or "other_test"
3107        assert_eq!(results.len(), 1);
3108        assert_eq!(results[0].symbol.as_deref(), Some("test"));
3109    }
3110
3111    // ==================== Expand Mode Tests ====================
3112
3113    #[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        // Search with expand mode
3134        let filter = QueryFilter {
3135            symbols_mode: true,
3136            expand: true,
3137            ..Default::default()
3138        };
3139        let results = engine.search("greet", filter).unwrap();
3140
3141        // Should have full function body in preview
3142        assert!(!results.is_empty());
3143        let result = &results[0];
3144        assert!(result.preview.contains("println"));
3145    }
3146
3147    // ==================== Edge Cases ====================
3148
3149    #[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        // Should fail when index doesn't exist
3179        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        // Search for special characters
3200        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, // Unicode word boundaries may not work as expected
3221            force: true,        // Bypass broad query detection for 2-char Unicode pattern
3222            ..Default::default()
3223        };
3224
3225        // Search for unicode characters
3226        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        // Search is case-sensitive
3248        let results = engine.search("Test", filter).unwrap();
3249        assert!(results.iter().any(|r| r.preview.contains("Test()")));
3250    }
3251
3252    // ==================== Determinism Tests ====================
3253
3254    #[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        // Run search multiple times
3274        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        // Results should be identical and sorted by path then line
3279        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        // Verify sorting (path ascending, then line ascending)
3290        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    // ==================== Combined Filter Tests ====================
3301
3302    #[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        // Combine language, kind, and file pattern filters
3321        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        // Should only find the function in src/main.rs
3331        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    // ==================== Helper Method Tests ====================
3337
3338    #[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        // Search for structs that contain "oin" (Point contains it, Line doesn't)
3380        let filter = QueryFilter {
3381            kind: Some(SymbolKind::Struct),
3382            symbols_mode: true,
3383            use_contains: true, // "oin" is substring of "Point"
3384            ..Default::default()
3385        };
3386        let results = engine.search("oin", filter).unwrap();
3387
3388        // Should find Point struct
3389        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    // ==================== Metadata Tests ====================
3395
3396    #[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        // Check metadata is present (status might be stale if run inside git repo)
3415        assert!(!response.results.is_empty());
3416        // Note: can_trust_results may be false if running in a git repo without branch index
3417    }
3418
3419    // ==================== Multi-language Tests ====================
3420
3421    #[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        // Should find greet in all three languages
3442        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}