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            anyhow::bail!("Index not found. Run 'rfx index' to build the cache first.");
274        }
275
276        // Validate cache integrity
277        if let Err(e) = self.cache.validate() {
278            anyhow::bail!(
279                "Cache appears to be corrupted: {}. Run 'rfx clear' followed by 'rfx index' to rebuild.",
280                e
281            );
282        }
283
284        // Get index status and warning (without printing warnings to stderr)
285        let (status, can_trust_results, warning) = self.get_index_status()?;
286
287        // Execute the search
288        let (results, total) = self.search_internal(pattern, filter.clone())?;
289
290        // Build pagination metadata
291        use crate::models::PaginationInfo;
292        let pagination = PaginationInfo {
293            total,
294            count: results.len(),
295            offset: filter.offset.unwrap_or(0),
296            limit: filter.limit,
297            has_more: total > filter.offset.unwrap_or(0) + results.len(),
298        };
299
300        // Always use grouped format (group results by file)
301        // Dependencies are loaded only when include_dependencies is true
302        let grouped_results = self.group_and_load_dependencies(
303            results,
304            filter.include_dependencies,
305            filter.context_lines,
306        )?;
307
308        Ok(QueryResponse {
309            ai_instruction: None, // AI instruction is generated by CLI/MCP layer, not here
310            status,
311            can_trust_results,
312            warning,
313            pagination,
314            results: grouped_results,
315        })
316    }
317
318    /// Execute a query and return matching results (legacy method)
319    ///
320    /// This method prints warnings to stderr and returns just the results.
321    /// For programmatic use, prefer `search_with_metadata()`.
322    pub fn search(&self, pattern: &str, filter: QueryFilter) -> Result<Vec<SearchResult>> {
323        log::info!(
324            "Executing query: pattern='{}', filter={:?}",
325            pattern,
326            filter
327        );
328
329        // Ensure cache exists
330        if !self.cache.exists() {
331            anyhow::bail!("Index not found. Run 'rfx index' to build the cache first.");
332        }
333
334        // Validate cache integrity
335        if let Err(e) = self.cache.validate() {
336            anyhow::bail!(
337                "Cache appears to be corrupted: {}. Run 'rfx clear' followed by 'rfx index' to rebuild.",
338                e
339            );
340        }
341
342        // Show non-blocking warnings about branch state and staleness
343        self.check_index_freshness(&filter)?;
344
345        // Execute the search (discard total count - legacy method doesn't use it)
346        let (mut results, _total_count) = self.search_internal(pattern, filter.clone())?;
347
348        // Load dependencies if requested
349        self.load_dependencies(&mut results, filter.include_dependencies)?;
350
351        Ok(results)
352    }
353
354    /// Internal search implementation (used by both search methods)
355    /// Returns (results, total_count) where total_count is the count before offset/limit
356    fn search_internal(
357        &self,
358        pattern: &str,
359        filter: QueryFilter,
360    ) -> Result<(Vec<SearchResult>, usize)> {
361        use std::time::{Duration, Instant};
362
363        // Start timeout timer if configured
364        let start_time = Instant::now();
365        let timeout = if filter.timeout_secs > 0 {
366            Some(Duration::from_secs(filter.timeout_secs))
367        } else {
368            None
369        };
370
371        // KEYWORD DETECTION (early): Check if this is a keyword query that should scan ALL files
372        // When a user searches for a language keyword (like "class", "function") with --symbols or --kind,
373        // we interpret it as "list all symbols of that type" and should scan ALL files,
374        // not just the first 100 candidates from trigram search.
375        //
376        // Requirements for keyword query mode:
377        // 1. Symbol mode active (--symbols or --kind)
378        // 2. Pattern matches a keyword in ANY supported language
379        //
380        // Note: --lang is optional. If specified, language filtering happens naturally in Phase 2/3.
381        // Empty pattern in symbol mode means "list all symbols of the requested kind" —
382        // treat it like a keyword query so we scan all files instead of failing the
383        // broad-query guard or returning zero trigram matches.
384        let is_keyword_query = if filter.symbols_mode || filter.kind.is_some() {
385            pattern.is_empty() || ParserFactory::get_all_keywords().contains(&pattern)
386        } else {
387            false
388        };
389
390        // KEYWORD-TO-KIND MAPPING: If user searches for a keyword without --kind, infer the kind
391        // Example: "class" → SymbolKind::Class, "function" → SymbolKind::Function
392        // This ensures keyword queries return only the relevant symbol type
393        let mut filter = filter.clone(); // Clone so we can modify it
394        if is_keyword_query
395            && filter.kind.is_none()
396            && let Some(inferred_kind) = Self::keyword_to_kind(pattern)
397        {
398            log::info!(
399                "Keyword '{}' mapped to kind {:?} (auto-inferred)",
400                pattern,
401                inferred_kind
402            );
403            filter.kind = Some(inferred_kind);
404        }
405
406        // EARLY BROAD QUERY DETECTION (Index Size Check)
407        // This check happens BEFORE the expensive trigram search to prevent hangs on large indexes
408        // For very large codebases (like Linux kernel with 62K files), even valid 3-char trigrams
409        // like "get" can take 10-30+ seconds to search. This early check prevents that hang.
410        //
411        // Criteria for early blocking:
412        // 1. Large index (> 20,000 files) AND
413        // 2. Short pattern (< 4 chars) AND
414        // 3. Not using regex (regex has its own trigram extraction) AND
415        // 4. Not a keyword query (keywords are intentionally broad) AND
416        // 5. Not forced by --force flag
417        if !filter.force && !filter.use_regex && !is_keyword_query {
418            let stats = self.cache.stats()?;
419            let total_files = stats.total_files;
420            let pattern_len = pattern.chars().count();
421
422            // Thresholds for early blocking:
423            // - Large index: 20,000+ files (approximately where performance degrades significantly)
424            // - Short pattern: < 4 chars (3-char trigrams are borderline, < 4 catches edge cases)
425            // Test overrides allow reducing thresholds for integration tests without creating 20K+ files
426            let large_index_threshold = filter.test_large_index_threshold.unwrap_or(20_000);
427            let short_pattern_threshold = filter.test_short_pattern_threshold.unwrap_or(4);
428
429            if total_files > large_index_threshold && pattern_len < short_pattern_threshold {
430                anyhow::bail!(
431                    "Query too broad - would be expensive to execute on this large index\n\
432                     \n\
433                     This index contains {} files, and pattern '{}' ({} characters) is too short for efficient searching.\n\
434                     On large codebases, short patterns can take 10-30+ seconds to complete.\n\
435                     \n\
436                     This query could:\n\
437                     • Hang for an extended period before returning results\n\
438                     • Return thousands of results\n\
439                     • Flood LLM context windows with excessive data\n\
440                     • Fail entirely\n\
441                     \n\
442                     Suggestions to narrow the query:\n\
443                     • Use a longer, more specific pattern (4+ characters recommended for large indexes)\n\
444                     • Add a language filter: --lang <language>\n\
445                     • Add a file filter: --glob <pattern> or --file <path>\n\
446                     • Use --force to bypass this check if you really need all results\n\
447                     \n\
448                     To force execution anyway:\n\
449                     rfx query \"{}\" --force",
450                    total_files,
451                    pattern,
452                    pattern_len,
453                    pattern
454                );
455            }
456        }
457
458        // PHASE 1: Get initial candidates (choose search strategy)
459        let mut results = if is_keyword_query {
460            // KEYWORD QUERY MODE: Scan all files (or files of target language if --lang specified)
461            // This ensures we find ALL classes/functions/etc, not just those in the first 100 trigram matches
462            if let Some(lang) = filter.language {
463                log::info!(
464                    "Keyword query detected for '{}' - scanning all {:?} files (bypassing trigram search)",
465                    pattern,
466                    lang
467                );
468            } else {
469                log::info!(
470                    "Keyword query detected for '{}' - scanning all files (bypassing trigram search)",
471                    pattern
472                );
473            }
474            self.get_all_language_files(&filter)?
475        } else if filter.use_regex {
476            // Regex pattern search with trigram optimization
477            self.get_regex_candidates(
478                pattern,
479                timeout.as_ref(),
480                &start_time,
481                filter.suppress_output,
482            )?
483        } else {
484            // Standard trigram-based full-text search
485            self.get_trigram_candidates(pattern, &filter)?
486        };
487
488        // EARLY LANGUAGE FILTER: Apply language filtering BEFORE broad query check
489        // This ensures we only parse files matching the language filter in Phase 2
490        // Critical for non-keyword queries to work correctly with accurate candidate counts
491        //
492        // Skip for keyword queries - those candidates are already pre-filtered by language
493        if !is_keyword_query && let Some(lang) = filter.language {
494            let before_count = results.len();
495            results.retain(|r| r.lang == lang);
496            log::debug!(
497                "Language filter ({:?}): reduced {} candidates to {} candidates",
498                lang,
499                before_count,
500                results.len()
501            );
502        }
503
504        // EARLY GLOB PATTERN FILTER: Apply glob/exclude filtering BEFORE broad query check
505        // This ensures candidate count reflects actual files that will be parsed
506        // Critical for queries like: rfx query "index" --symbols --glob "src/**/*.rs"
507        if !filter.glob_patterns.is_empty() || !filter.exclude_patterns.is_empty() {
508            use globset::{Glob, GlobSetBuilder};
509
510            // Build include matcher (if patterns specified)
511            let include_matcher = if !filter.glob_patterns.is_empty() {
512                let mut builder = GlobSetBuilder::new();
513                for pattern in &filter.glob_patterns {
514                    // Normalize pattern to ensure LLM-generated patterns work correctly
515                    let normalized = Self::normalize_glob_pattern(pattern);
516                    match Glob::new(&normalized) {
517                        Ok(glob) => {
518                            builder.add(glob);
519                        }
520                        Err(e) => {
521                            log::warn!("Invalid glob pattern '{}': {}", pattern, e);
522                        }
523                    }
524                }
525                match builder.build() {
526                    Ok(matcher) => Some(matcher),
527                    Err(e) => {
528                        log::warn!("Failed to build glob matcher: {}", e);
529                        None
530                    }
531                }
532            } else {
533                None
534            };
535
536            // Build exclude matcher (if patterns specified)
537            let exclude_matcher = if !filter.exclude_patterns.is_empty() {
538                let mut builder = GlobSetBuilder::new();
539                for pattern in &filter.exclude_patterns {
540                    // Normalize pattern to ensure LLM-generated patterns work correctly
541                    let normalized = Self::normalize_glob_pattern(pattern);
542                    match Glob::new(&normalized) {
543                        Ok(glob) => {
544                            builder.add(glob);
545                        }
546                        Err(e) => {
547                            log::warn!("Invalid exclude pattern '{}': {}", pattern, e);
548                        }
549                    }
550                }
551                match builder.build() {
552                    Ok(matcher) => Some(matcher),
553                    Err(e) => {
554                        log::warn!("Failed to build exclude matcher: {}", e);
555                        None
556                    }
557                }
558            } else {
559                None
560            };
561
562            // Apply filters
563            let before_count = results.len();
564            results.retain(|r| {
565                // If include patterns specified, path must match at least one
566                let included = if let Some(ref matcher) = include_matcher {
567                    matcher.is_match(&r.path)
568                } else {
569                    true // No include patterns = include all
570                };
571
572                // If exclude patterns specified, path must NOT match any
573                let excluded = if let Some(ref matcher) = exclude_matcher {
574                    matcher.is_match(&r.path)
575                } else {
576                    false // No exclude patterns = exclude none
577                };
578
579                included && !excluded
580            });
581            log::debug!(
582                "Glob filter: reduced {} candidates to {} candidates",
583                before_count,
584                results.len()
585            );
586        }
587
588        // Check timeout after Phase 1
589        if let Some(timeout_duration) = timeout
590            && start_time.elapsed() > timeout_duration
591        {
592            anyhow::bail!(
593                "Query timeout exceeded ({} seconds).\n\
594                     \n\
595                     The query took too long to complete. Try one of these approaches:\n\
596                     • Use a more specific search pattern (longer patterns = faster search)\n\
597                     • Add a language filter with --lang to narrow the search space\n\
598                     • Add a file filter with --file to search specific directories\n\
599                     • Increase the timeout with --timeout <seconds>\n\
600                     \n\
601                     Example: rfx query \"{}\" --lang rust --timeout 60",
602                filter.timeout_secs,
603                pattern
604            );
605        }
606
607        // BROAD QUERY DETECTION: Check if query is too expensive BEFORE parsing
608        // This protects LLM users from accidentally running expensive queries that flood context windows
609        if !filter.force {
610            let candidate_count = results.len();
611            let pattern_len = pattern.chars().count();
612
613            // Condition 1: Pattern too short (< 3 chars can't use trigram optimization efficiently)
614            // Exception: Allow short keyword queries (e.g., "fn", "if") since they scan all language files
615            let is_short_pattern = pattern_len < 3 && !filter.use_regex && !is_keyword_query;
616
617            // Condition 2: AST query without glob restriction on large codebases
618            // Allow on small codebases (< 100 files) but require glob for larger ones
619            let is_broad_ast =
620                filter.use_ast && filter.glob_patterns.is_empty() && candidate_count >= 100;
621
622            // Condition 3: Query-type-aware threshold for symbol/AST parsing
623            // Different thresholds based on actual performance characteristics:
624            // - AST without glob: 100 files (allow small codebases, block large ones)
625            // - AST with glob: 10,000 files (~5 seconds max)
626            // - Keyword queries: 20,000 files (~3 seconds max) - scan all files of language
627            // - Trigram-filtered symbols: 50,000 files (~5 seconds max) - very fast due to trigram filtering
628            let threshold = if filter.use_ast && filter.glob_patterns.is_empty() {
629                100 // AST without glob - allow small codebases
630            } else if filter.use_ast {
631                10_000 // AST with glob restriction
632            } else if is_keyword_query {
633                20_000 // Keyword queries (e.g., "class", "function")
634            } else {
635                50_000 // Trigram-filtered symbol queries
636            };
637
638            let has_many_candidates = candidate_count > threshold
639                && (filter.symbols_mode || filter.kind.is_some() || filter.use_ast);
640
641            if is_short_pattern || has_many_candidates || is_broad_ast {
642                let reason = if is_short_pattern {
643                    format!(
644                        "Pattern '{}' is too short ({} characters). Short patterns bypass trigram optimization and require scanning many files.",
645                        pattern, pattern_len
646                    )
647                } else if is_broad_ast {
648                    format!(
649                        "AST query without --glob restriction will scan the entire codebase ({} files). AST queries are SLOW (500ms-10s+).",
650                        candidate_count
651                    )
652                } else if is_keyword_query {
653                    format!(
654                        "Keyword query '{}' matched {} files. This query scans all files of the target language, which will take significant time and produce excessive results.",
655                        pattern, candidate_count
656                    )
657                } else {
658                    format!(
659                        "Query matched {} files. Parsing this many files with --symbols or --kind will take significant time and produce excessive results.",
660                        candidate_count
661                    )
662                };
663
664                let suggestions = if is_short_pattern {
665                    vec![
666                        "• Use a longer, more specific pattern (3+ characters recommended)",
667                        "• Add a language filter: --lang <language>",
668                        "• Add a file path filter: --file <path> or --glob <pattern>",
669                        "• Use --force to bypass this check if you really need all results",
670                    ]
671                } else if is_broad_ast {
672                    vec![
673                        "• Add --glob to restrict AST query to specific files: --glob 'src/**/*.rs'",
674                        "• Use --symbols instead (10-100x faster in 95% of cases)",
675                        "• Use --force to bypass this check if you need a full codebase scan",
676                    ]
677                } else if is_keyword_query {
678                    vec![
679                        "• Add a language filter to reduce files scanned: --lang <language>",
680                        "• Add glob patterns to search specific directories: --glob 'src/**/*.rs'",
681                        "• Add --kind to filter to specific symbol types: --kind function",
682                        "• Use a more specific pattern instead of a keyword",
683                        "• Use --force to bypass this check if you need all results",
684                    ]
685                } else {
686                    vec![
687                        "• Add a language filter to reduce candidate set: --lang <language>",
688                        "• Add glob patterns to search specific directories: --glob 'src/**/*.rs'",
689                        "• Use a more specific search pattern",
690                        "• Use --force to bypass this check if you need all results",
691                    ]
692                };
693
694                // Build the command snippet showing current flags
695                let mut cmd_flags = String::new();
696                if filter.symbols_mode {
697                    cmd_flags.push_str("--symbols ");
698                }
699                if let Some(ref lang) = filter.language {
700                    cmd_flags.push_str(&format!("--lang {:?} ", lang));
701                }
702                if let Some(ref kind) = filter.kind {
703                    cmd_flags.push_str(&format!("--kind {:?} ", kind));
704                }
705                if filter.use_ast {
706                    cmd_flags.push_str("--ast ");
707                }
708
709                anyhow::bail!(
710                    "Query too broad - would be expensive to execute\n\
711                     \n\
712                     {}\n\
713                     \n\
714                     This query could:\n\
715                     • Hang for an extended period before returning results\n\
716                     • Return thousands of results\n\
717                     • Flood LLM context windows with excessive data\n\
718                     • Fail entirely\n\
719                     \n\
720                     Suggestions to narrow the query:\n\
721                     {}\n\
722                     \n\
723                     To force execution anyway:\n\
724                     rfx query \"{}\" --force {}",
725                    reason,
726                    suggestions.join("\n             "),
727                    pattern,
728                    cmd_flags
729                );
730            }
731        }
732
733        // DETERMINISTIC SORTING: Sort candidates early for deterministic results
734        // This ensures results are always returned in the same order
735        if filter.symbols_mode || filter.kind.is_some() || filter.use_ast {
736            results.sort_by(|a, b| {
737                a.path
738                    .cmp(&b.path)
739                    .then_with(|| a.span.start_line.cmp(&b.span.start_line))
740            });
741
742            // Warn if many candidates need parsing (helps users refine queries)
743            let candidate_count = results.len();
744            if candidate_count > 1000 && !filter.suppress_output {
745                output::warn(&format!(
746                    "Pattern '{}' matched {} files - parsing may take some time. Consider using --file, --glob, or a more specific pattern to narrow the search.",
747                    pattern, candidate_count
748                ));
749            } else if candidate_count > 100 {
750                log::info!(
751                    "Parsing {} candidate files for symbol extraction",
752                    candidate_count
753                );
754            }
755        }
756
757        // PHASE 2: Enrich with symbol information or AST pattern matching (if needed)
758        if filter.use_ast {
759            // AST pattern matching: Execute Tree-sitter query on candidate files
760            results = self.enrich_with_ast(results, pattern, filter.language)?;
761        } else if filter.symbols_mode || filter.kind.is_some() {
762            // Symbol enrichment: Parse candidate files and extract symbol definitions
763            results = self.enrich_with_symbols(results, pattern, &filter)?;
764        }
765
766        // PHASE 3: Apply post-enrichment filters
767        // Note: Language and glob filters are applied in Phase 1 (before broad query check)
768        // Only kind, file_pattern, and exact filters are applied here
769
770        // Deduplicate symbols: the same source location can be emitted as both
771        // Function and Method by some parsers.  Keep the first hit for each
772        // (path, start_line, symbol_name) triple so --kind function doesn't
773        // return the same definition twice.
774        if filter.symbols_mode || filter.kind.is_some() {
775            let mut seen = std::collections::HashSet::<(String, usize, Option<String>)>::new();
776            results.retain(|r| seen.insert((r.path.clone(), r.span.start_line, r.symbol.clone())));
777        }
778
779        // Apply kind filter (only relevant for symbol searches)
780        // Special case: --kind function also includes methods (methods are functions in classes)
781        if let Some(ref kind) = filter.kind {
782            results.retain(|r| {
783                if matches!(kind, SymbolKind::Function) {
784                    // When searching for functions, also include methods
785                    matches!(r.kind, SymbolKind::Function | SymbolKind::Method)
786                } else {
787                    r.kind == *kind
788                }
789            });
790        }
791
792        // Apply file path filter (substring match)
793        if let Some(ref file_pattern) = filter.file_pattern {
794            results.retain(|r| r.path.contains(file_pattern));
795        }
796
797        // Apply exact name filter (only for symbol searches)
798        if filter.exact && filter.symbols_mode {
799            results.retain(|r| r.symbol.as_deref() == Some(pattern));
800        }
801
802        // Expand symbol bodies if requested
803        // Works for both symbol-mode and regex searches (if regex matched a symbol definition)
804        if filter.expand {
805            // Load content store to fetch full symbol bodies
806            let content_path = self.cache.path().join("content.bin");
807            if let Ok(content_reader) = ContentReader::open(&content_path) {
808                for result in &mut results {
809                    // Only expand if the result has a meaningful span (not just a single line)
810                    if result.span.start_line < result.span.end_line {
811                        // Find the file_id for this result's path
812                        if let Some(file_id) = Self::find_file_id(&content_reader, &result.path) {
813                            // Fetch the full span content
814                            if let Ok(content) = content_reader.get_file_content(file_id) {
815                                let lines: Vec<&str> = content.lines().collect();
816                                let start_idx = result.span.start_line.saturating_sub(1);
817                                let end_idx = result.span.end_line.min(lines.len());
818
819                                if start_idx < end_idx {
820                                    let full_body = lines[start_idx..end_idx].join("\n");
821                                    result.preview = full_body;
822                                }
823                            }
824                        }
825                    }
826                }
827            }
828        }
829
830        // Step 4: Deduplicate by path if paths-only mode
831        if filter.paths_only {
832            use std::collections::HashSet;
833            let mut seen_paths = HashSet::new();
834            results.retain(|r| seen_paths.insert(r.path.clone()));
835        }
836
837        // Step 5: Sort results deterministically (by path, then line number)
838        results.sort_by(|a, b| {
839            a.path
840                .cmp(&b.path)
841                .then_with(|| a.span.start_line.cmp(&b.span.start_line))
842        });
843
844        // Capture total count AFTER all filtering but BEFORE pagination (offset/limit)
845        // This is the total number of results the user can paginate through
846        let total_count = results.len();
847
848        // Step 5.5: Apply offset (pagination)
849        if let Some(offset) = filter.offset {
850            if offset < results.len() {
851                results = results.into_iter().skip(offset).collect();
852            } else {
853                // Offset beyond results - return empty
854                results.clear();
855            }
856        }
857
858        // Step 6: Apply limit
859        if let Some(limit) = filter.limit {
860            results.truncate(limit);
861        }
862
863        log::info!(
864            "Query returned {} results (total before pagination: {})",
865            results.len(),
866            total_count
867        );
868
869        Ok((results, total_count))
870    }
871
872    /// Search for symbols by exact name match
873    pub fn find_symbol(&self, name: &str) -> Result<Vec<SearchResult>> {
874        let filter = QueryFilter {
875            symbols_mode: true,
876            ..Default::default()
877        };
878        self.search(name, filter)
879    }
880
881    /// Search using a Tree-sitter AST pattern
882    pub fn search_ast(&self, pattern: &str, lang: Option<Language>) -> Result<Vec<SearchResult>> {
883        let filter = QueryFilter {
884            language: lang,
885            use_ast: true,
886            ..Default::default()
887        };
888
889        self.search(pattern, filter)
890    }
891
892    /// Execute AST query on all indexed files (no trigram filtering)
893    ///
894    /// WARNING: This method scans the entire codebase (500ms-2s+).
895    /// In 95% of cases, use --symbols instead which is 10-100x faster.
896    ///
897    /// # Algorithm
898    /// 1. Get all indexed files for the specified language
899    /// 2. Apply glob/exclude filters to reduce file set
900    /// 3. Load file contents for all matching files
901    /// 4. Execute AST query pattern using Tree-sitter
902    /// 5. Apply remaining filters and return results
903    ///
904    /// # Performance
905    /// - Parses entire codebase (not just trigram candidates)
906    /// - Expected: 500ms-2s for medium codebases, 2-10s for large codebases
907    /// - Use --glob to limit scope for better performance
908    ///
909    /// # Requirements
910    /// - Language must be specified (AST queries are language-specific)
911    /// - AST pattern must be valid S-expression syntax
912    pub fn search_ast_all_files(
913        &self,
914        ast_pattern: &str,
915        filter: QueryFilter,
916    ) -> Result<Vec<SearchResult>> {
917        log::info!(
918            "Executing AST query on all files: pattern='{}', filter={:?}",
919            ast_pattern,
920            filter
921        );
922
923        // Require language for AST queries
924        let lang = filter.language.ok_or_else(|| anyhow::anyhow!(
925            "Language must be specified for AST pattern matching. Use --lang to specify the language.\n\
926             \n\
927             Example: rfx query \"(function_definition) @fn\" --ast --lang python"
928        ))?;
929
930        // Ensure cache exists
931        if !self.cache.exists() {
932            anyhow::bail!("Index not found. Run 'rfx index' to build the cache first.");
933        }
934
935        // Show non-blocking warnings about branch state and staleness
936        self.check_index_freshness(&filter)?;
937
938        // Load content store
939        let content_path = self.cache.path().join("content.bin");
940        let content_reader =
941            ContentReader::open(&content_path).context("Failed to open content store")?;
942
943        // Build glob matchers ONCE before file iteration (performance optimization)
944        use globset::{Glob, GlobSetBuilder};
945
946        let include_matcher = if !filter.glob_patterns.is_empty() {
947            let mut builder = GlobSetBuilder::new();
948            for pattern in &filter.glob_patterns {
949                // Normalize pattern to ensure LLM-generated patterns work correctly
950                let normalized = Self::normalize_glob_pattern(pattern);
951                if let Ok(glob) = Glob::new(&normalized) {
952                    builder.add(glob);
953                }
954            }
955            builder.build().ok()
956        } else {
957            None
958        };
959
960        let exclude_matcher = if !filter.exclude_patterns.is_empty() {
961            let mut builder = GlobSetBuilder::new();
962            for pattern in &filter.exclude_patterns {
963                // Normalize pattern to ensure LLM-generated patterns work correctly
964                let normalized = Self::normalize_glob_pattern(pattern);
965                if let Ok(glob) = Glob::new(&normalized) {
966                    builder.add(glob);
967                }
968            }
969            builder.build().ok()
970        } else {
971            None
972        };
973
974        // Get all files matching the language and glob filters
975        let mut candidates: Vec<SearchResult> = Vec::new();
976
977        for file_id in 0..content_reader.file_count() {
978            let file_path = match content_reader.get_file_path(file_id as u32) {
979                Some(p) => p,
980                None => continue,
981            };
982
983            // Detect language from file extension
984            let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
985            let detected_lang = Language::from_extension(ext);
986
987            // Filter by language
988            if detected_lang != lang {
989                continue;
990            }
991
992            let file_path_str = file_path.to_string_lossy().to_string();
993
994            // Apply glob/exclude filters BEFORE loading content (performance optimization)
995            let included = include_matcher
996                .as_ref()
997                .is_none_or(|m| m.is_match(&file_path_str));
998            let excluded = exclude_matcher
999                .as_ref()
1000                .is_some_and(|m| m.is_match(&file_path_str));
1001
1002            if !included || excluded {
1003                continue;
1004            }
1005
1006            // Create a dummy candidate for this file (AST query will replace it)
1007            candidates.push(SearchResult {
1008                path: file_path_str,
1009                lang: detected_lang,
1010                span: Span {
1011                    start_line: 1,
1012                    end_line: 1,
1013                },
1014                symbol: None,
1015                kind: SymbolKind::Unknown("ast_query".to_string()),
1016                preview: String::new(),
1017                dependencies: None,
1018            });
1019        }
1020
1021        log::info!(
1022            "AST query scanning {} files for language {:?}",
1023            candidates.len(),
1024            lang
1025        );
1026
1027        // BROAD QUERY DETECTION: Block large AST queries without glob restriction
1028        // Allow small codebases (<100 files) but require --glob for larger ones
1029        if !filter.force && filter.glob_patterns.is_empty() && candidates.len() >= 100 {
1030            anyhow::bail!(
1031                "Query too broad - would be expensive to execute\n\
1032                 \n\
1033                 AST query without --glob restriction will scan the ENTIRE codebase ({} files). AST queries are SLOW (500ms-10s+).\n\
1034                 \n\
1035                 This query could:\n\
1036                 • Hang for an extended period before returning results\n\
1037                 • Return thousands of results\n\
1038                 • Flood LLM context windows with excessive data\n\
1039                 • Fail entirely\n\
1040                 \n\
1041                 Suggestions to narrow the query:\n\
1042                 • Add --glob to restrict AST query to specific files: --glob 'src/**/*.rs'\n\
1043                 • Use --symbols instead (10-100x faster in 95% of cases)\n\
1044                 • Use --force to bypass this check if you need a full codebase scan\n\
1045                 \n\
1046                 To force execution anyway:\n\
1047                 rfx query \"{}\" --force --ast --lang {:?}",
1048                candidates.len(),
1049                ast_pattern,
1050                lang
1051            );
1052        }
1053
1054        if candidates.is_empty() {
1055            if !filter.suppress_output {
1056                output::warn(&format!(
1057                    "No files found for language {:?}. Check your language filter or glob patterns.",
1058                    lang
1059                ));
1060            }
1061            return Ok(Vec::new());
1062        }
1063
1064        // Execute the AST query on all candidate files
1065        // This will load file contents and parse them with tree-sitter
1066        let mut results = self.enrich_with_ast(candidates, ast_pattern, filter.language)?;
1067
1068        log::debug!("AST query found {} matches before filtering", results.len());
1069
1070        // Apply remaining filters (same as search_internal Phase 3)
1071
1072        // Apply kind filter
1073        if let Some(ref kind) = filter.kind {
1074            results.retain(|r| {
1075                if matches!(kind, SymbolKind::Function) {
1076                    matches!(r.kind, SymbolKind::Function | SymbolKind::Method)
1077                } else {
1078                    r.kind == *kind
1079                }
1080            });
1081        }
1082
1083        // Note: exact filter doesn't make sense for AST queries (pattern is S-expression, not symbol name)
1084
1085        // Expand symbol bodies if requested
1086        if filter.expand {
1087            let content_path = self.cache.path().join("content.bin");
1088            if let Ok(content_reader) = ContentReader::open(&content_path) {
1089                for result in &mut results {
1090                    if result.span.start_line < result.span.end_line
1091                        && let Some(file_id) = Self::find_file_id(&content_reader, &result.path)
1092                        && let Ok(content) = content_reader.get_file_content(file_id)
1093                    {
1094                        let lines: Vec<&str> = content.lines().collect();
1095                        let start_idx = result.span.start_line.saturating_sub(1);
1096                        let end_idx = result.span.end_line.min(lines.len());
1097
1098                        if start_idx < end_idx {
1099                            let full_body = lines[start_idx..end_idx].join("\n");
1100                            result.preview = full_body;
1101                        }
1102                    }
1103                }
1104            }
1105        }
1106
1107        // Deduplicate by path if paths-only mode
1108        if filter.paths_only {
1109            use std::collections::HashSet;
1110            let mut seen_paths = HashSet::new();
1111            results.retain(|r| seen_paths.insert(r.path.clone()));
1112        }
1113
1114        // Sort results deterministically
1115        results.sort_by(|a, b| {
1116            a.path
1117                .cmp(&b.path)
1118                .then_with(|| a.span.start_line.cmp(&b.span.start_line))
1119        });
1120
1121        // Apply offset (pagination)
1122        if let Some(offset) = filter.offset {
1123            if offset < results.len() {
1124                results = results.into_iter().skip(offset).collect();
1125            } else {
1126                results.clear();
1127            }
1128        }
1129
1130        // Apply limit
1131        if let Some(limit) = filter.limit {
1132            results.truncate(limit);
1133        }
1134
1135        log::info!("AST query returned {} results", results.len());
1136
1137        // Load dependencies if requested
1138        self.load_dependencies(&mut results, filter.include_dependencies)?;
1139
1140        Ok(results)
1141    }
1142
1143    /// Search using AST pattern with separate text pattern for trigram filtering
1144    ///
1145    /// This allows efficient AST queries by:
1146    /// 1. Using text_pattern for Phase 1 trigram filtering (narrows to candidate files)
1147    /// 2. Using ast_pattern for Phase 2 AST matching (structure-aware filtering)
1148    ///
1149    /// # Example
1150    /// ```ignore
1151    /// // Find async functions: trigram search for "fn ", AST match for function_item
1152    /// engine.search_ast_with_text_filter("fn ", "(function_item (async))", filter)?;
1153    /// ```
1154    pub fn search_ast_with_text_filter(
1155        &self,
1156        text_pattern: &str,
1157        ast_pattern: &str,
1158        filter: QueryFilter,
1159    ) -> Result<Vec<SearchResult>> {
1160        log::info!(
1161            "Executing AST query with text filter: text='{}', ast='{}', filter={:?}",
1162            text_pattern,
1163            ast_pattern,
1164            filter
1165        );
1166
1167        // Ensure cache exists
1168        if !self.cache.exists() {
1169            anyhow::bail!("Index not found. Run 'rfx index' to build the cache first.");
1170        }
1171
1172        // Show non-blocking warnings about branch state and staleness
1173        self.check_index_freshness(&filter)?;
1174
1175        // Start timeout timer if configured
1176        use std::time::{Duration, Instant};
1177        let start_time = Instant::now();
1178        let timeout = if filter.timeout_secs > 0 {
1179            Some(Duration::from_secs(filter.timeout_secs))
1180        } else {
1181            None
1182        };
1183
1184        // PHASE 1: Get initial candidates using text pattern (trigram search)
1185        let candidates = if filter.use_regex {
1186            self.get_regex_candidates(
1187                text_pattern,
1188                timeout.as_ref(),
1189                &start_time,
1190                filter.suppress_output,
1191            )?
1192        } else {
1193            self.get_trigram_candidates(text_pattern, &filter)?
1194        };
1195
1196        log::debug!("Phase 1 found {} candidate locations", candidates.len());
1197
1198        // PHASE 2: Execute AST query on candidates
1199        let mut results = self.enrich_with_ast(candidates, ast_pattern, filter.language)?;
1200
1201        log::debug!("Phase 2 AST matching found {} results", results.len());
1202
1203        // PHASE 3: Apply filters
1204        if let Some(lang) = filter.language {
1205            results.retain(|r| r.lang == lang);
1206        }
1207
1208        if let Some(ref kind) = filter.kind {
1209            results.retain(|r| {
1210                if matches!(kind, SymbolKind::Function) {
1211                    matches!(r.kind, SymbolKind::Function | SymbolKind::Method)
1212                } else {
1213                    r.kind == *kind
1214                }
1215            });
1216        }
1217
1218        if let Some(ref file_pattern) = filter.file_pattern {
1219            results.retain(|r| r.path.contains(file_pattern));
1220        }
1221
1222        // Apply glob pattern filters (same logic as in search_internal)
1223        if !filter.glob_patterns.is_empty() || !filter.exclude_patterns.is_empty() {
1224            use globset::{Glob, GlobSetBuilder};
1225
1226            let include_matcher = if !filter.glob_patterns.is_empty() {
1227                let mut builder = GlobSetBuilder::new();
1228                for pattern in &filter.glob_patterns {
1229                    // Normalize pattern to ensure LLM-generated patterns work correctly
1230                    let normalized = Self::normalize_glob_pattern(pattern);
1231                    if let Ok(glob) = Glob::new(&normalized) {
1232                        builder.add(glob);
1233                    }
1234                }
1235                builder.build().ok()
1236            } else {
1237                None
1238            };
1239
1240            let exclude_matcher = if !filter.exclude_patterns.is_empty() {
1241                let mut builder = GlobSetBuilder::new();
1242                for pattern in &filter.exclude_patterns {
1243                    // Normalize pattern to ensure LLM-generated patterns work correctly
1244                    let normalized = Self::normalize_glob_pattern(pattern);
1245                    if let Ok(glob) = Glob::new(&normalized) {
1246                        builder.add(glob);
1247                    }
1248                }
1249                builder.build().ok()
1250            } else {
1251                None
1252            };
1253
1254            results.retain(|r| {
1255                let included = include_matcher.as_ref().is_none_or(|m| m.is_match(&r.path));
1256                let excluded = exclude_matcher
1257                    .as_ref()
1258                    .is_some_and(|m| m.is_match(&r.path));
1259                included && !excluded
1260            });
1261        }
1262
1263        if filter.exact && filter.symbols_mode {
1264            results.retain(|r| r.symbol.as_deref() == Some(text_pattern));
1265        }
1266
1267        // Expand symbol bodies if requested
1268        if filter.expand {
1269            let content_path = self.cache.path().join("content.bin");
1270            if let Ok(content_reader) = ContentReader::open(&content_path) {
1271                for result in &mut results {
1272                    if result.span.start_line < result.span.end_line
1273                        && let Some(file_id) = Self::find_file_id(&content_reader, &result.path)
1274                        && let Ok(content) = content_reader.get_file_content(file_id)
1275                    {
1276                        let lines: Vec<&str> = content.lines().collect();
1277                        let start_idx = result.span.start_line.saturating_sub(1);
1278                        let end_idx = result.span.end_line.min(lines.len());
1279
1280                        if start_idx < end_idx {
1281                            let full_body = lines[start_idx..end_idx].join("\n");
1282                            result.preview = full_body;
1283                        }
1284                    }
1285                }
1286            }
1287        }
1288
1289        // Sort results deterministically
1290        results.sort_by(|a, b| {
1291            a.path
1292                .cmp(&b.path)
1293                .then_with(|| a.span.start_line.cmp(&b.span.start_line))
1294        });
1295
1296        // Apply offset (pagination)
1297        if let Some(offset) = filter.offset {
1298            if offset < results.len() {
1299                results = results.into_iter().skip(offset).collect();
1300            } else {
1301                results.clear();
1302            }
1303        }
1304
1305        // Apply limit
1306        if let Some(limit) = filter.limit {
1307            results.truncate(limit);
1308        }
1309
1310        log::info!("AST query returned {} results", results.len());
1311
1312        Ok(results)
1313    }
1314
1315    /// List all symbols of a specific kind
1316    pub fn list_by_kind(&self, kind: SymbolKind) -> Result<Vec<SearchResult>> {
1317        let filter = QueryFilter {
1318            kind: Some(kind),
1319            symbols_mode: true,
1320            ..Default::default()
1321        };
1322
1323        self.search("*", filter)
1324    }
1325
1326    /// Enrich text match candidates with symbol information by parsing files
1327    ///
1328    /// Takes a list of text match candidates and extracts symbol information at those locations.
1329    ///
1330    /// # Algorithm
1331    /// 1. Group candidates by file_id for efficient processing
1332    /// 2. Parse each file with tree-sitter to extract ALL symbols
1333    /// 3. Filter symbols based on matching strategy:
1334    ///    - If use_regex=true: Extract symbols whose line spans overlap with candidate locations
1335    ///    - If use_contains=true: Filter symbols by substring match on symbol name
1336    ///    - Default: Filter symbols by exact name match
1337    /// 4. Return filtered symbol results
1338    ///
1339    /// # Performance
1340    /// Only parses files that have text matches, so typically 10-100 files
1341    /// instead of the entire codebase (62K+ files).
1342    ///
1343    /// # Optimizations
1344    /// 1. Language filtering: Skips files with unsupported languages (no parsers)
1345    /// 2. Parallel processing: Uses Rayon to parse files concurrently across CPU cores
1346    fn enrich_with_symbols(
1347        &self,
1348        candidates: Vec<SearchResult>,
1349        pattern: &str,
1350        filter: &QueryFilter,
1351    ) -> Result<Vec<SearchResult>> {
1352        // Load content store for file reading
1353        let content_path = self.cache.path().join("content.bin");
1354        let content_reader =
1355            ContentReader::open(&content_path).context("Failed to open content store")?;
1356
1357        // Load trigram index for file path lookups
1358        let trigrams_path = self.cache.path().join("trigrams.bin");
1359        let trigram_index = if trigrams_path.exists() {
1360            TrigramIndex::load(&trigrams_path)?
1361        } else {
1362            Self::rebuild_trigram_index(&content_reader)?
1363        };
1364
1365        // Open symbol cache for reading cached symbols
1366        let symbol_cache = crate::symbol_cache::SymbolCache::open(self.cache.path())
1367            .context("Failed to open symbol cache")?;
1368
1369        // Load file hashes for current branch for cache lookups
1370        let root = self.cache.workspace_root();
1371        let branch =
1372            crate::git::get_current_branch(&root).unwrap_or_else(|_| "_default".to_string());
1373        let file_hashes = self
1374            .cache
1375            .load_hashes_for_branch(&branch)
1376            .context("Failed to load file hashes")?;
1377        log::debug!(
1378            "Loaded {} file hashes for branch '{}' for symbol cache lookups",
1379            file_hashes.len(),
1380            branch
1381        );
1382
1383        // Group candidates by file, filtering out unsupported languages
1384        use std::collections::HashMap;
1385        let mut files_by_path: HashMap<String, Vec<SearchResult>> = HashMap::new();
1386        let mut skipped_unsupported = 0;
1387
1388        for candidate in candidates {
1389            // Skip files with unsupported languages (no parser available)
1390            if !candidate.lang.is_supported() {
1391                skipped_unsupported += 1;
1392                continue;
1393            }
1394
1395            files_by_path
1396                .entry(candidate.path.clone())
1397                .or_default()
1398                .push(candidate);
1399        }
1400
1401        let total_files = files_by_path.len();
1402        log::debug!(
1403            "Processing {} candidate files for symbol enrichment (skipped {} unsupported language files)",
1404            total_files,
1405            skipped_unsupported
1406        );
1407
1408        // Warn if pattern is very broad (may take time to parse all files)
1409        if total_files > 1000 && !filter.suppress_output {
1410            output::warn(&format!(
1411                "Pattern '{}' matched {} files. This may take some time to parse. Consider using a more specific pattern or adding --lang/--file filters to narrow the search.",
1412                pattern, total_files
1413            ));
1414        }
1415
1416        // Convert to vec for parallel processing
1417        let mut files_to_process: Vec<String> = files_by_path.keys().cloned().collect();
1418
1419        // PHASE 2a: Line-based pre-filtering (skip files where ALL matches are in comments/strings)
1420        // This reduces tree-sitter parsing workload by 2-5x for most queries
1421        let mut files_to_skip: std::collections::HashSet<String> = std::collections::HashSet::new();
1422
1423        for file_path in &files_to_process {
1424            // Get the language for this file
1425            let ext = std::path::Path::new(file_path)
1426                .extension()
1427                .and_then(|e| e.to_str())
1428                .unwrap_or("");
1429            let lang = Language::from_extension(ext);
1430
1431            // Get line filter for this language (if available)
1432            if let Some(line_filter) = crate::line_filter::get_filter(lang) {
1433                // Find file_id for this path
1434                let file_id =
1435                    match Self::find_file_id_by_path(&content_reader, &trigram_index, file_path) {
1436                        Some(id) => id,
1437                        None => continue,
1438                    };
1439
1440                // Load file content
1441                let content = match content_reader.get_file_content(file_id) {
1442                    Ok(c) => c,
1443                    Err(_) => continue,
1444                };
1445
1446                // Check if ALL pattern occurrences are in comments/strings
1447                let mut all_in_non_code = true;
1448                for line in content.lines() {
1449                    // Find all occurrences of the pattern in this line
1450                    let mut search_start = 0;
1451                    while let Some(pos) = line[search_start..].find(pattern) {
1452                        let absolute_pos = search_start + pos;
1453
1454                        // Check if this occurrence is in code (not comment/string)
1455                        let in_comment = line_filter.is_in_comment(line, absolute_pos);
1456                        let in_string = line_filter.is_in_string(line, absolute_pos);
1457
1458                        if !in_comment && !in_string {
1459                            // Found at least one occurrence in actual code
1460                            all_in_non_code = false;
1461                            break;
1462                        }
1463
1464                        search_start = absolute_pos + pattern.len();
1465                    }
1466
1467                    if !all_in_non_code {
1468                        break;
1469                    }
1470                }
1471
1472                // If ALL occurrences are in comments/strings, skip this file
1473                if all_in_non_code {
1474                    // Double-check: make sure there was at least one occurrence
1475                    if content.contains(pattern) {
1476                        files_to_skip.insert(file_path.clone());
1477                        log::debug!(
1478                            "Pre-filter: Skipping {} (all matches in comments/strings)",
1479                            file_path
1480                        );
1481                    }
1482                }
1483            }
1484        }
1485
1486        // Filter out files we're skipping
1487        files_to_process.retain(|path| !files_to_skip.contains(path));
1488
1489        log::debug!(
1490            "Pre-filter: Skipped {} files where all matches are in comments/strings (parsing {} files)",
1491            files_to_skip.len(),
1492            files_to_process.len()
1493        );
1494
1495        // Configure thread pool for parallel processing (use 80% of available cores, capped at 8)
1496        let num_threads = {
1497            let available_cores = std::thread::available_parallelism()
1498                .map(|n| n.get())
1499                .unwrap_or(4);
1500            // Use 80% of available cores (minimum 1, maximum 8) to avoid locking the system
1501            // Cap at 8 to prevent diminishing returns from cache contention on high-core systems
1502            ((available_cores as f64 * 0.8).ceil() as usize).clamp(1, 8)
1503        };
1504
1505        log::debug!(
1506            "Using {} threads for parallel symbol extraction (out of {} available cores)",
1507            num_threads,
1508            std::thread::available_parallelism()
1509                .map(|n| n.get())
1510                .unwrap_or(4)
1511        );
1512
1513        // Build a custom thread pool with limited threads
1514        let pool = rayon::ThreadPoolBuilder::new()
1515            .num_threads(num_threads)
1516            .build()
1517            .context("Failed to create thread pool for symbol extraction")?;
1518
1519        // OPTIMIZATION: Batch read all cached symbols in ONE database transaction
1520        // This is 10-30x faster than calling get() individually for each file
1521
1522        // Step 1: Collect file paths that have hashes
1523        let files_with_hashes: Vec<String> = files_to_process
1524            .iter()
1525            .filter(|path| file_hashes.contains_key(path.as_str()))
1526            .cloned()
1527            .collect();
1528
1529        // Step 2: Batch lookup file_ids for all paths
1530        let file_id_map = self
1531            .cache
1532            .batch_get_file_ids(&files_with_hashes)
1533            .context("Failed to batch lookup file IDs")?;
1534
1535        // Step 3: Build (file_id, hash, path) tuples for batch_get_with_kind
1536        let file_lookup_tuples: Vec<(i64, String, String)> = files_with_hashes
1537            .iter()
1538            .filter_map(|path| {
1539                let file_id = file_id_map.get(path)?;
1540                let hash = file_hashes.get(path.as_str())?;
1541                Some((*file_id, hash.clone(), path.clone()))
1542            })
1543            .collect();
1544
1545        // Step 4: Batch read symbols with kind filtering (uses junction table + integer joins)
1546        let batch_results = symbol_cache
1547            .batch_get_with_kind(&file_lookup_tuples, filter.kind.clone())
1548            .context("Failed to batch read symbol cache")?;
1549
1550        // Step 5: Separate files into cached vs need-to-parse
1551        let mut cached_symbols: HashMap<String, Vec<SearchResult>> = HashMap::new();
1552        let mut files_needing_parse: Vec<String> = Vec::new();
1553
1554        // Build path lookup from file_id
1555        let id_to_path: HashMap<i64, String> = file_id_map
1556            .iter()
1557            .map(|(path, id)| (*id, path.clone()))
1558            .collect();
1559
1560        // Process cached results
1561        for (file_id, symbols) in batch_results {
1562            if let Some(file_path) = id_to_path.get(&file_id) {
1563                cached_symbols.insert(file_path.clone(), symbols);
1564            }
1565        }
1566
1567        // Files with hashes but not in cache results need parsing
1568        for path in &files_with_hashes {
1569            if file_id_map.contains_key(path) && !cached_symbols.contains_key(path) {
1570                files_needing_parse.push(path.clone());
1571            }
1572        }
1573
1574        // Add files without hashes to parse list
1575        for file_path in &files_to_process {
1576            if !file_hashes.contains_key(file_path.as_str()) {
1577                files_needing_parse.push(file_path.clone());
1578            }
1579        }
1580
1581        log::debug!(
1582            "Symbol cache: {} hits, {} need parsing",
1583            cached_symbols.len(),
1584            files_needing_parse.len()
1585        );
1586
1587        // Parse files in parallel using custom thread pool (only cache misses)
1588        use rayon::prelude::*;
1589
1590        let parsed_symbols: Vec<SearchResult> = pool.install(|| {
1591            files_needing_parse
1592                .par_iter()
1593                .flat_map(|file_path| {
1594                    // Find file_id for this path
1595                    let file_id = match Self::find_file_id_by_path(
1596                        &content_reader,
1597                        &trigram_index,
1598                        file_path,
1599                    ) {
1600                        Some(id) => id,
1601                        None => {
1602                            log::warn!("Could not find file_id for path: {}", file_path);
1603                            return Vec::new();
1604                        }
1605                    };
1606
1607                    let content = match content_reader.get_file_content(file_id) {
1608                        Ok(c) => c,
1609                        Err(e) => {
1610                            log::warn!("Failed to read file {}: {}", file_path, e);
1611                            return Vec::new();
1612                        }
1613                    };
1614
1615                    // Detect language
1616                    let ext = std::path::Path::new(file_path)
1617                        .extension()
1618                        .and_then(|e| e.to_str())
1619                        .unwrap_or("");
1620                    let lang = Language::from_extension(ext);
1621
1622                    // Parse file to extract symbols
1623                    let symbols = match ParserFactory::parse(file_path, content, lang) {
1624                        Ok(symbols) => {
1625                            log::debug!("Parsed {} symbols from {}", symbols.len(), file_path);
1626                            symbols
1627                        }
1628                        Err(e) => {
1629                            log::debug!("Failed to parse {}: {}", file_path, e);
1630                            Vec::new()
1631                        }
1632                    };
1633
1634                    // Cache the parsed symbols (ignore errors - caching is best-effort)
1635                    if let Some(file_hash) = file_hashes.get(file_path.as_str())
1636                        && let Err(e) = symbol_cache.set(file_path, file_hash, &symbols)
1637                    {
1638                        log::debug!("Failed to cache symbols for {}: {}", file_path, e);
1639                    }
1640
1641                    symbols
1642                })
1643                .collect()
1644        });
1645
1646        // Combine cached and parsed symbols
1647        let mut all_symbols: Vec<SearchResult> = Vec::new();
1648
1649        // Add all cached symbols
1650        for symbols in cached_symbols.values() {
1651            all_symbols.extend_from_slice(symbols);
1652        }
1653
1654        // Add all parsed symbols
1655        all_symbols.extend(parsed_symbols);
1656
1657        // KEYWORD DETECTION: Check if pattern is a language keyword (e.g., "class", "function")
1658        // If it matches a keyword AND symbols_mode is true, interpret as "list all symbols of that type"
1659        // rather than looking for a symbol literally named "class" or "function"
1660        //
1661        // IMPORTANT: Only check keywords for languages that will pass Phase 3 filtering.
1662        // If a language filter is specified, only check that language's keywords.
1663        // Otherwise, check all languages present in the symbol results.
1664        let is_keyword_query = {
1665            // Determine which language to check keywords for
1666            let lang_to_check = if let Some(lang) = filter.language {
1667                // Language filter specified - check that language only
1668                // This ensures keyword detection aligns with Phase 3 language filtering
1669                vec![lang]
1670            } else {
1671                // No language filter - check all languages that appear in the actual symbols
1672                // (not candidates, but the parsed symbols that made it through)
1673                // This handles mixed-language codebases correctly
1674                let mut langs: Vec<Language> =
1675                    all_symbols.iter().map(|s| s.lang).collect::<Vec<_>>();
1676                langs.sort_by(|a, b| format!("{:?}", a).cmp(&format!("{:?}", b))); // Deterministic ordering
1677                langs.dedup(); // Remove duplicates after sorting
1678                langs
1679            };
1680
1681            // Check if pattern matches a keyword in any of the relevant languages
1682            lang_to_check
1683                .iter()
1684                .any(|lang| ParserFactory::get_keywords(*lang).contains(&pattern))
1685        };
1686
1687        // If pattern is a keyword (like "class" or "function"), skip name-based filtering
1688        // and return all symbols (kind filtering happens in Phase 3)
1689        let filtered: Vec<SearchResult> = if is_keyword_query {
1690            log::info!(
1691                "Pattern '{}' is a language keyword - listing all symbols (kind filtering will be applied in Phase 3)",
1692                pattern
1693            );
1694            all_symbols
1695        } else if filter.use_regex {
1696            // For regex queries, candidates already matched content via regex in Phase 1.
1697            // Extract symbols whose line spans overlap with the candidate locations.
1698            // This ensures symbols are found at the locations where the regex matched.
1699
1700            // Build a map of (file_path, line_no) from candidates
1701            use std::collections::{HashMap, HashSet};
1702            let mut candidate_lines: HashMap<String, HashSet<usize>> = HashMap::new();
1703            for candidate in &files_by_path {
1704                for cand in candidate.1 {
1705                    candidate_lines
1706                        .entry(candidate.0.clone())
1707                        .or_default()
1708                        .insert(cand.span.start_line);
1709                }
1710            }
1711
1712            // Filter symbols whose spans overlap with candidate lines
1713            all_symbols
1714                .into_iter()
1715                .filter(|sym| {
1716                    if let Some(lines) = candidate_lines.get(&sym.path) {
1717                        // Check if symbol's line span overlaps with any candidate line
1718                        for line in sym.span.start_line..=sym.span.end_line {
1719                            if lines.contains(&line) {
1720                                return true;
1721                            }
1722                        }
1723                    }
1724                    false
1725                })
1726                .collect()
1727        } else if filter.use_contains {
1728            // Substring match (opt-in with --contains)
1729            all_symbols
1730                .into_iter()
1731                .filter(|sym| sym.symbol.as_deref().is_some_and(|s| s.contains(pattern)))
1732                .collect()
1733        } else {
1734            // Exact match (default)
1735            all_symbols
1736                .into_iter()
1737                .filter(|sym| sym.symbol.as_deref() == Some(pattern))
1738                .collect()
1739        };
1740
1741        log::info!(
1742            "Symbol enrichment found {} matches for pattern '{}'",
1743            filtered.len(),
1744            pattern
1745        );
1746
1747        Ok(filtered)
1748    }
1749
1750    /// Enrich text match candidates with AST pattern matching
1751    ///
1752    /// Takes a list of text match candidates and executes a Tree-sitter AST query
1753    /// on the candidate files, returning only matches that satisfy the AST pattern.
1754    ///
1755    /// # Algorithm
1756    /// 1. Extract unique file paths from candidates
1757    /// 2. Load file contents for each candidate file
1758    /// 3. Execute AST query pattern using Tree-sitter
1759    /// 4. Return AST matches
1760    ///
1761    /// # Performance
1762    /// Only parses files that have text matches, so typically 10-100 files
1763    /// instead of the entire codebase (62K+ files).
1764    ///
1765    /// # Requirements
1766    /// - Language must be specified (AST queries are language-specific)
1767    /// - AST pattern must be valid S-expression syntax
1768    fn enrich_with_ast(
1769        &self,
1770        candidates: Vec<SearchResult>,
1771        ast_pattern: &str,
1772        language: Option<Language>,
1773    ) -> Result<Vec<SearchResult>> {
1774        // Require language for AST queries
1775        let lang = language.ok_or_else(|| anyhow::anyhow!(
1776            "Language must be specified for AST pattern matching. Use --lang to specify the language."
1777        ))?;
1778
1779        // Load content store for file reading
1780        let content_path = self.cache.path().join("content.bin");
1781        let content_reader =
1782            ContentReader::open(&content_path).context("Failed to open content store")?;
1783
1784        // Load trigram index for file path lookups
1785        let trigrams_path = self.cache.path().join("trigrams.bin");
1786        let trigram_index = if trigrams_path.exists() {
1787            TrigramIndex::load(&trigrams_path)?
1788        } else {
1789            Self::rebuild_trigram_index(&content_reader)?
1790        };
1791
1792        // Collect unique file paths from candidates and load their contents
1793        use std::collections::HashMap;
1794        let mut file_contents: HashMap<String, String> = HashMap::new();
1795
1796        for candidate in &candidates {
1797            if file_contents.contains_key(&candidate.path) {
1798                continue;
1799            }
1800
1801            // Find file_id for this path
1802            let file_id = match Self::find_file_id_by_path(
1803                &content_reader,
1804                &trigram_index,
1805                &candidate.path,
1806            ) {
1807                Some(id) => id,
1808                None => {
1809                    log::warn!("Could not find file_id for path: {}", candidate.path);
1810                    continue;
1811                }
1812            };
1813
1814            // Load file content
1815            let content = match content_reader.get_file_content(file_id) {
1816                Ok(c) => c,
1817                Err(e) => {
1818                    log::warn!("Failed to read file {}: {}", candidate.path, e);
1819                    continue;
1820                }
1821            };
1822
1823            file_contents.insert(candidate.path.clone(), content.to_string());
1824        }
1825
1826        log::debug!(
1827            "Executing AST query on {} candidate files with language {:?}",
1828            file_contents.len(),
1829            lang
1830        );
1831
1832        // Execute AST query using the ast_query module
1833        let results =
1834            crate::ast_query::execute_ast_query(candidates, ast_pattern, lang, &file_contents)?;
1835
1836        log::info!(
1837            "AST query found {} matches for pattern '{}'",
1838            results.len(),
1839            ast_pattern
1840        );
1841
1842        Ok(results)
1843    }
1844
1845    /// Helper to find file_id by path string
1846    fn find_file_id_by_path(
1847        content_reader: &ContentReader,
1848        trigram_index: &TrigramIndex,
1849        target_path: &str,
1850    ) -> Option<u32> {
1851        // Try trigram index first (faster)
1852        for file_id in 0..trigram_index.file_count() {
1853            if let Some(path) = trigram_index.get_file(file_id as u32)
1854                && path.to_string_lossy() == target_path
1855            {
1856                return Some(file_id as u32);
1857            }
1858        }
1859
1860        // Fallback to content reader
1861        for file_id in 0..content_reader.file_count() {
1862            if let Some(path) = content_reader.get_file_path(file_id as u32)
1863                && path.to_string_lossy() == target_path
1864            {
1865                return Some(file_id as u32);
1866            }
1867        }
1868
1869        None
1870    }
1871
1872    /// Map keyword patterns to SymbolKind for auto-inference
1873    ///
1874    /// When users search for keywords like "class" or "function" with --symbols,
1875    /// automatically infer the kind filter to return only symbols of that type.
1876    ///
1877    /// This makes keyword queries more intuitive: searching for "class" returns
1878    /// only classes, not all symbols.
1879    fn keyword_to_kind(keyword: &str) -> Option<SymbolKind> {
1880        filter::keyword_to_kind(keyword)
1881    }
1882
1883    /// Get all files matching the language filter (for keyword queries)
1884    ///
1885    /// This method bypasses trigram search and returns ALL files of the specified language.
1886    /// Used for keyword queries like "list all classes" where we need complete coverage,
1887    /// not just the first 100 candidates from a trigram search.
1888    ///
1889    /// Similar to `search_ast_all_files()` but works for symbol queries instead of AST queries.
1890    fn get_all_language_files(&self, filter: &QueryFilter) -> Result<Vec<SearchResult>> {
1891        // Language filter is optional - if not specified, scan all files
1892        // If specified, only scan files of that language
1893
1894        // Load content store
1895        let content_path = self.cache.path().join("content.bin");
1896        let content_reader =
1897            ContentReader::open(&content_path).context("Failed to open content store")?;
1898
1899        // Build glob matchers if specified (for filtering)
1900        use globset::{Glob, GlobSetBuilder};
1901
1902        let include_matcher = if !filter.glob_patterns.is_empty() {
1903            let mut builder = GlobSetBuilder::new();
1904            for pattern in &filter.glob_patterns {
1905                let normalized = Self::normalize_glob_pattern(pattern);
1906                if let Ok(glob) = Glob::new(&normalized) {
1907                    builder.add(glob);
1908                }
1909            }
1910            builder.build().ok()
1911        } else {
1912            None
1913        };
1914
1915        let exclude_matcher = if !filter.exclude_patterns.is_empty() {
1916            let mut builder = GlobSetBuilder::new();
1917            for pattern in &filter.exclude_patterns {
1918                let normalized = Self::normalize_glob_pattern(pattern);
1919                if let Ok(glob) = Glob::new(&normalized) {
1920                    builder.add(glob);
1921                }
1922            }
1923            builder.build().ok()
1924        } else {
1925            None
1926        };
1927
1928        // Scan all files and filter by language + glob patterns
1929        let mut candidates: Vec<SearchResult> = Vec::new();
1930
1931        for file_id in 0..content_reader.file_count() {
1932            let file_path = match content_reader.get_file_path(file_id as u32) {
1933                Some(p) => p,
1934                None => continue,
1935            };
1936
1937            // Detect language from file extension
1938            let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
1939            let detected_lang = Language::from_extension(ext);
1940
1941            // Filter by language (if specified)
1942            if let Some(lang) = filter.language
1943                && detected_lang != lang
1944            {
1945                continue;
1946            }
1947
1948            let file_path_str = file_path.to_string_lossy().to_string();
1949
1950            // Apply glob/exclude filters
1951            let included = include_matcher
1952                .as_ref()
1953                .is_none_or(|m| m.is_match(&file_path_str));
1954            let excluded = exclude_matcher
1955                .as_ref()
1956                .is_some_and(|m| m.is_match(&file_path_str));
1957
1958            if !included || excluded {
1959                continue;
1960            }
1961
1962            // Apply file path filter if specified
1963            if let Some(ref file_pattern) = filter.file_pattern
1964                && !file_path_str.contains(file_pattern)
1965            {
1966                continue;
1967            }
1968
1969            // Create a dummy candidate for this file
1970            // Phase 2 (symbol enrichment) will parse it and extract actual symbols
1971            candidates.push(SearchResult {
1972                path: file_path_str,
1973                lang: detected_lang,
1974                span: Span {
1975                    start_line: 1,
1976                    end_line: 1,
1977                },
1978                symbol: None,
1979                kind: SymbolKind::Unknown("keyword_query".to_string()),
1980                preview: String::new(),
1981                dependencies: None,
1982            });
1983        }
1984
1985        if let Some(lang) = filter.language {
1986            log::info!(
1987                "Keyword query will scan {} {:?} files for symbol extraction",
1988                candidates.len(),
1989                lang
1990            );
1991        } else {
1992            log::info!(
1993                "Keyword query will scan {} files (all languages) for symbol extraction",
1994                candidates.len()
1995            );
1996        }
1997
1998        Ok(candidates)
1999    }
2000
2001    /// Get candidate results using trigram-based full-text search
2002    fn get_trigram_candidates(
2003        &self,
2004        pattern: &str,
2005        filter: &QueryFilter,
2006    ) -> Result<Vec<SearchResult>> {
2007        // Load content store
2008        let content_path = self.cache.path().join("content.bin");
2009        let content_reader =
2010            ContentReader::open(&content_path).context("Failed to open content store")?;
2011
2012        // Patterns shorter than 3 chars have no trigrams, so the trigram index always
2013        // returns empty.  Fall back to a linear scan of the content store so that
2014        // --force (which bypasses the broad-query guard) still produces real results.
2015        if pattern.chars().count() < 3 {
2016            log::info!(
2017                "Pattern '{}' is shorter than 3 chars — trigram index cannot be used, \
2018                 falling back to linear scan",
2019                pattern
2020            );
2021            return self.linear_scan_candidates(pattern, filter, &content_reader);
2022        }
2023
2024        // Load trigram index from disk (or rebuild if missing)
2025        let trigrams_path = self.cache.path().join("trigrams.bin");
2026        let trigram_index = if trigrams_path.exists() {
2027            match TrigramIndex::load(&trigrams_path) {
2028                Ok(index) => {
2029                    log::debug!(
2030                        "Loaded trigram index from disk: {} trigrams, {} files",
2031                        index.trigram_count(),
2032                        index.file_count()
2033                    );
2034                    index
2035                }
2036                Err(e) => {
2037                    log::warn!("Failed to load trigram index from disk: {}", e);
2038                    log::warn!("Rebuilding trigram index from content store...");
2039                    Self::rebuild_trigram_index(&content_reader)?
2040                }
2041            }
2042        } else {
2043            log::debug!("trigrams.bin not found, rebuilding from content store");
2044            Self::rebuild_trigram_index(&content_reader)?
2045        };
2046
2047        // Search using trigrams
2048        let candidates = trigram_index.search(pattern);
2049        log::debug!(
2050            "Found {} candidate locations from trigram search",
2051            candidates.len()
2052        );
2053
2054        // Clone pattern to owned String for thread safety
2055        let pattern_owned = pattern.to_string();
2056
2057        // Compile regex once if in regex mode (before parallel processing for efficiency)
2058        let compiled_regex = if filter.use_regex {
2059            match Regex::new(&pattern_owned) {
2060                Ok(re) => Some(re),
2061                Err(e) => {
2062                    log::error!("Invalid regex pattern '{}': {}", pattern_owned, e);
2063                    anyhow::bail!("Invalid regex pattern '{}': {}", pattern_owned, e);
2064                }
2065            }
2066        } else {
2067            None
2068        };
2069
2070        // Group candidates by file for efficient processing
2071        use std::collections::HashMap;
2072        let mut candidates_by_file: HashMap<u32, Vec<crate::trigram::FileLocation>> =
2073            HashMap::new();
2074        for loc in candidates {
2075            candidates_by_file.entry(loc.file_id).or_default().push(loc);
2076        }
2077
2078        log::debug!(
2079            "Scanning {} files with trigram matches",
2080            candidates_by_file.len()
2081        );
2082
2083        // Process files in parallel using rayon
2084        use rayon::prelude::*;
2085
2086        let results: Vec<SearchResult> = candidates_by_file
2087            .par_iter()
2088            .flat_map(|(file_id, locations)| {
2089                // Get file metadata
2090                let file_path = match trigram_index.get_file(*file_id) {
2091                    Some(p) => p,
2092                    None => return Vec::new(),
2093                };
2094
2095                let content = match content_reader.get_file_content(*file_id) {
2096                    Ok(c) => c,
2097                    Err(_) => return Vec::new(),
2098                };
2099
2100                let file_path_str = file_path.to_string_lossy().to_string();
2101
2102                // Detect language once per file
2103                let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
2104                let lang = Language::from_extension(ext);
2105
2106                // Split content into lines once
2107                let lines: Vec<&str> = content.lines().collect();
2108
2109                // Use a HashSet to deduplicate results by line number
2110                let mut seen_lines: std::collections::HashSet<usize> =
2111                    std::collections::HashSet::new();
2112                let mut file_results = Vec::new();
2113
2114                // Only check the specific lines indicated by trigram posting lists
2115                for loc in locations {
2116                    let line_no = loc.line_no as usize;
2117
2118                    // Skip if we've already processed this line
2119                    if seen_lines.contains(&line_no) {
2120                        continue;
2121                    }
2122
2123                    // Bounds check
2124                    if line_no == 0 || line_no > lines.len() {
2125                        log::debug!(
2126                            "Line {} out of bounds (file has {} lines)",
2127                            line_no,
2128                            lines.len()
2129                        );
2130                        continue;
2131                    }
2132
2133                    let line = lines[line_no - 1];
2134
2135                    // Apply matching strategy based on filter mode:
2136                    // - Default: Word-boundary matching (restrictive - finds whole identifiers)
2137                    // - --contains: Substring matching (expansive - finds pattern anywhere)
2138                    // - --regex: Actual regex matching (controlled by pattern itself)
2139                    let line_matches = if filter.use_regex {
2140                        // Regex matching - use pre-compiled regex for efficiency
2141                        // The regex was compiled once outside the parallel loop
2142                        compiled_regex
2143                            .as_ref()
2144                            .map(|re| re.is_match(line))
2145                            .unwrap_or(false)
2146                    } else if filter.use_contains {
2147                        // Substring matching (expansive)
2148                        line.contains(&pattern_owned)
2149                    } else {
2150                        // Word-boundary matching (restrictive, default)
2151                        Self::has_word_boundary_match(line, &pattern_owned)
2152                    };
2153
2154                    if !line_matches {
2155                        continue;
2156                    }
2157
2158                    seen_lines.insert(line_no);
2159
2160                    // Create a text match result (no symbol lookup for performance)
2161                    file_results.push(SearchResult {
2162                        path: file_path_str.clone(),
2163                        lang,
2164                        kind: SymbolKind::Unknown("text_match".to_string()),
2165                        symbol: None, // No symbol name for text matches (avoid duplication)
2166                        span: Span {
2167                            start_line: line_no,
2168                            end_line: line_no,
2169                        },
2170                        preview: line.to_string(),
2171                        dependencies: None,
2172                    });
2173                }
2174
2175                file_results
2176            })
2177            .collect();
2178
2179        Ok(results)
2180    }
2181
2182    /// Linear scan fallback for patterns shorter than 3 characters.
2183    ///
2184    /// The trigram index requires 3-char n-grams; patterns like "fn" or "i" yield
2185    /// zero trigrams and therefore zero results.  This method scans every file in
2186    /// the content store directly using the same matching logic (word-boundary,
2187    /// contains, or regex) so short-pattern queries always return real results.
2188    fn linear_scan_candidates(
2189        &self,
2190        pattern: &str,
2191        filter: &QueryFilter,
2192        content_reader: &ContentReader,
2193    ) -> Result<Vec<SearchResult>> {
2194        use rayon::prelude::*;
2195
2196        let pattern_owned = pattern.to_string();
2197        let file_count = content_reader.file_count();
2198
2199        let compiled_regex = if filter.use_regex {
2200            match Regex::new(&pattern_owned) {
2201                Ok(re) => Some(re),
2202                Err(e) => anyhow::bail!("Invalid regex pattern '{}': {}", pattern_owned, e),
2203            }
2204        } else {
2205            None
2206        };
2207
2208        let results: Vec<SearchResult> = (0..file_count as u32)
2209            .collect::<Vec<_>>()
2210            .par_iter()
2211            .flat_map(|&file_id| {
2212                let file_path = match content_reader.get_file_path(file_id) {
2213                    Some(p) => p.to_path_buf(),
2214                    None => return Vec::new(),
2215                };
2216                let content = match content_reader.get_file_content(file_id) {
2217                    Ok(c) => c,
2218                    Err(_) => return Vec::new(),
2219                };
2220
2221                let file_path_str = file_path.to_string_lossy().to_string();
2222                let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
2223                let lang = Language::from_extension(ext);
2224
2225                let mut seen_lines = std::collections::HashSet::new();
2226                let mut file_results = Vec::new();
2227
2228                for (line_idx, line) in content.lines().enumerate() {
2229                    let line_no = line_idx + 1;
2230                    if seen_lines.contains(&line_no) {
2231                        continue;
2232                    }
2233
2234                    let line_matches = if filter.use_regex {
2235                        compiled_regex
2236                            .as_ref()
2237                            .map(|re| re.is_match(line))
2238                            .unwrap_or(false)
2239                    } else if filter.use_contains {
2240                        line.contains(&pattern_owned)
2241                    } else {
2242                        Self::has_word_boundary_match(line, &pattern_owned)
2243                    };
2244
2245                    if !line_matches {
2246                        continue;
2247                    }
2248
2249                    seen_lines.insert(line_no);
2250                    file_results.push(SearchResult {
2251                        path: file_path_str.clone(),
2252                        lang,
2253                        kind: SymbolKind::Unknown("text_match".to_string()),
2254                        symbol: None,
2255                        span: Span {
2256                            start_line: line_no,
2257                            end_line: line_no,
2258                        },
2259                        preview: line.to_string(),
2260                        dependencies: None,
2261                    });
2262                }
2263
2264                file_results
2265            })
2266            .collect();
2267
2268        log::info!(
2269            "Linear scan (short pattern '{}') found {} results across {} files",
2270            pattern,
2271            results.len(),
2272            file_count
2273        );
2274        Ok(results)
2275    }
2276
2277    /// Get candidate results using regex patterns with trigram optimization
2278    ///
2279    /// # Algorithm
2280    ///
2281    /// 1. Extract literal sequences from the regex pattern (≥3 chars)
2282    /// 2. If literals found: search for files containing ANY of the literals (UNION)
2283    /// 3. If no literals: fall back to full content scan
2284    /// 4. Compile regex and verify matches in candidate files
2285    /// 5. Return matching results with context
2286    ///
2287    /// # File Selection Strategy
2288    ///
2289    /// Uses UNION of files containing any literal (conservative approach):
2290    /// - For alternation patterns `(a|b)`: Correctly searches files with a OR b
2291    /// - For sequential patterns `a.*b`: Searches files with a OR b (may include extra files)
2292    /// - Trade-off: Ensures correctness at the cost of scanning 2-3x more files for sequential patterns
2293    /// - Performance impact is minimal due to memory-mapped I/O (<5ms overhead typically)
2294    ///
2295    /// # Performance
2296    ///
2297    /// - Best case (pattern with literals): <20ms (trigram optimization)
2298    /// - Typical case (alternation/sequential): 5-15ms on small codebases (<100 files)
2299    /// - Worst case (no literals like `.*`): ~100ms (full scan)
2300    fn get_regex_candidates(
2301        &self,
2302        pattern: &str,
2303        timeout: Option<&std::time::Duration>,
2304        start_time: &std::time::Instant,
2305        suppress_output: bool,
2306    ) -> Result<Vec<SearchResult>> {
2307        // Step 1: Compile the regex
2308        let regex =
2309            Regex::new(pattern).with_context(|| format!("Invalid regex pattern: {}", pattern))?;
2310
2311        // Check timeout before expensive operations
2312        if let Some(timeout_duration) = timeout
2313            && start_time.elapsed() > *timeout_duration
2314        {
2315            anyhow::bail!(
2316                "Query timeout exceeded ({} seconds) during regex compilation",
2317                timeout_duration.as_secs()
2318            );
2319        }
2320
2321        // Step 2: Extract trigrams from regex
2322        let trigrams = extract_trigrams_from_regex(pattern);
2323
2324        // Load content store
2325        let content_path = self.cache.path().join("content.bin");
2326        let content_reader =
2327            ContentReader::open(&content_path).context("Failed to open content store")?;
2328
2329        let mut results = Vec::new();
2330
2331        if trigrams.is_empty() {
2332            // No trigrams - fall back to full scan
2333            if !suppress_output {
2334                output::warn(&format!(
2335                    "Regex pattern '{}' has no literals (≥3 chars), falling back to full content scan. This may be slow on large codebases. Consider using patterns with literal text.",
2336                    pattern
2337                ));
2338            }
2339
2340            // Scan all files
2341            for file_id in 0..content_reader.file_count() {
2342                let file_path = content_reader
2343                    .get_file_path(file_id as u32)
2344                    .context("Invalid file_id")?;
2345                let content = content_reader.get_file_content(file_id as u32)?;
2346
2347                self.find_regex_matches_in_file(&regex, file_path, content, &mut results)?;
2348            }
2349        } else {
2350            // Use trigrams to narrow down candidates
2351            log::debug!(
2352                "Using {} trigrams to narrow regex search candidates",
2353                trigrams.len()
2354            );
2355
2356            // Load trigram index
2357            let trigrams_path = self.cache.path().join("trigrams.bin");
2358            let trigram_index = if trigrams_path.exists() {
2359                TrigramIndex::load(&trigrams_path)?
2360            } else {
2361                Self::rebuild_trigram_index(&content_reader)?
2362            };
2363
2364            // Extract the literal sequences from the regex pattern
2365            use crate::regex_trigrams::extract_literal_sequences;
2366            let literals = extract_literal_sequences(pattern);
2367
2368            if literals.is_empty() {
2369                log::warn!(
2370                    "Regex extraction found trigrams but no literal sequences - this shouldn't happen"
2371                );
2372                // Fall back to full scan
2373                for file_id in 0..content_reader.file_count() {
2374                    let file_path = content_reader
2375                        .get_file_path(file_id as u32)
2376                        .context("Invalid file_id")?;
2377                    let content = content_reader.get_file_content(file_id as u32)?;
2378                    self.find_regex_matches_in_file(&regex, file_path, content, &mut results)?;
2379                }
2380            } else {
2381                // Search for each literal sequence and union the results
2382                // This ensures we find matches for ANY literal (important for alternation patterns like (a|b))
2383                // Trade-off: May scan more files than necessary for sequential patterns (a.*b),
2384                // but ensures correctness for all regex patterns
2385                use std::collections::HashSet;
2386                let mut candidate_files: HashSet<u32> = HashSet::new();
2387
2388                for literal in &literals {
2389                    // Search for this literal in the trigram index
2390                    let candidates = trigram_index.search(literal);
2391                    let file_ids: HashSet<u32> = candidates.iter().map(|loc| loc.file_id).collect();
2392
2393                    log::debug!("Literal '{}' found in {} files", literal, file_ids.len());
2394
2395                    // Union with existing candidate files (not intersection)
2396                    // This ensures we search files containing ANY of the literals
2397                    candidate_files.extend(file_ids);
2398                }
2399
2400                let final_candidates = candidate_files;
2401                log::debug!(
2402                    "After union: searching {} files that contain any literal",
2403                    final_candidates.len()
2404                );
2405
2406                // Verify regex matches in candidate files only
2407                for &file_id in &final_candidates {
2408                    let file_path = trigram_index
2409                        .get_file(file_id)
2410                        .context("Invalid file_id from trigram search")?;
2411                    let content = content_reader.get_file_content(file_id)?;
2412
2413                    self.find_regex_matches_in_file(&regex, file_path, content, &mut results)?;
2414                }
2415            }
2416        }
2417
2418        log::info!(
2419            "Regex search found {} matches for pattern '{}'",
2420            results.len(),
2421            pattern
2422        );
2423        Ok(results)
2424    }
2425
2426    /// Find all regex matches in a single file
2427    fn find_regex_matches_in_file(
2428        &self,
2429        regex: &Regex,
2430        file_path: &std::path::Path,
2431        content: &str,
2432        results: &mut Vec<SearchResult>,
2433    ) -> Result<()> {
2434        let file_path_str = file_path.to_string_lossy().to_string();
2435
2436        // Detect language from file extension
2437        let ext = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
2438        let lang = Language::from_extension(ext);
2439
2440        // Find all regex matches line by line
2441        for (line_idx, line) in content.lines().enumerate() {
2442            if regex.is_match(line) {
2443                let line_no = line_idx + 1;
2444
2445                // Create text match result
2446                // Note: We don't extract symbol names from regex matches because:
2447                // 1. Regex might match partial identifiers (e.g., "UserController" in "ListUserController")
2448                // 2. Regex might match across language-specific delimiters (namespaces, scopes, etc.)
2449                // 3. Accurate symbol extraction requires tree-sitter parsing (expensive)
2450                // The user can see the full context in the 'preview' field
2451                results.push(SearchResult {
2452                    path: file_path_str.clone(),
2453                    lang,
2454                    kind: SymbolKind::Unknown("regex_match".to_string()),
2455                    symbol: None, // No symbol name for regex matches
2456                    span: Span {
2457                        start_line: line_no,
2458                        end_line: line_no,
2459                    },
2460                    preview: line.to_string(),
2461                    dependencies: None,
2462                });
2463            }
2464        }
2465
2466        Ok(())
2467    }
2468
2469    fn find_file_id(content_reader: &ContentReader, target_path: &str) -> Option<u32> {
2470        result::find_file_id(content_reader, target_path)
2471    }
2472
2473    fn rebuild_trigram_index(content_reader: &ContentReader) -> Result<TrigramIndex> {
2474        result::rebuild_trigram_index(content_reader)
2475    }
2476
2477    fn normalize_glob_pattern(pattern: &str) -> String {
2478        result::normalize_glob_pattern(pattern)
2479    }
2480
2481    fn has_word_boundary_match(line: &str, pattern: &str) -> bool {
2482        filter::has_word_boundary_match(line, pattern)
2483    }
2484
2485    /// Get index status for programmatic use (doesn't print warnings)
2486    ///
2487    /// Returns (status, can_trust_results, warning) tuple for JSON output.
2488    /// This is optimized for AI agents to detect staleness and auto-reindex.
2489    pub fn get_index_status(&self) -> Result<(IndexStatus, bool, Option<IndexWarning>)> {
2490        let root = self.cache.workspace_root();
2491
2492        // Check git state if in a git repo
2493        if crate::git::is_git_repo(&root)
2494            && let Ok(current_branch) = crate::git::get_current_branch(&root)
2495        {
2496            // Check if we're on a different branch than what was indexed
2497            if !self.cache.branch_exists(&current_branch).unwrap_or(false) {
2498                let warning = IndexWarning {
2499                    reason: format!("Branch '{}' has not been indexed", current_branch),
2500                    action_required: "rfx index".to_string(),
2501                    files_modified: None,
2502                    details: Some(IndexWarningDetails {
2503                        current_branch: Some(current_branch),
2504                        indexed_branch: None,
2505                        current_commit: None,
2506                        indexed_commit: None,
2507                    }),
2508                };
2509                return Ok((IndexStatus::Stale, false, Some(warning)));
2510            }
2511
2512            // Branch exists - check if commit changed
2513            if let (Ok(current_commit), Ok(branch_info)) = (
2514                crate::git::get_current_commit(&root),
2515                self.cache.get_branch_info(&current_branch),
2516            ) {
2517                if branch_info.commit_sha != current_commit {
2518                    let warning = IndexWarning {
2519                        reason: format!(
2520                            "Commit changed from {} to {}",
2521                            &branch_info.commit_sha[..7],
2522                            &current_commit[..7]
2523                        ),
2524                        action_required: "rfx index".to_string(),
2525                        files_modified: None,
2526                        details: Some(IndexWarningDetails {
2527                            current_branch: Some(current_branch.clone()),
2528                            indexed_branch: Some(current_branch.clone()),
2529                            current_commit: Some(current_commit.clone()),
2530                            indexed_commit: Some(branch_info.commit_sha.clone()),
2531                        }),
2532                    };
2533                    return Ok((IndexStatus::Stale, false, Some(warning)));
2534                }
2535
2536                // If commits match, do a quick file freshness check
2537                if let Ok(branch_files) = self.cache.get_branch_files(&current_branch) {
2538                    let mut checked = 0;
2539                    let mut changed = 0;
2540                    const SAMPLE_SIZE: usize = 10;
2541
2542                    for (path, _indexed_hash) in branch_files.iter().take(SAMPLE_SIZE) {
2543                        checked += 1;
2544                        let file_path = std::path::Path::new(path);
2545
2546                        if let Ok(metadata) = std::fs::metadata(file_path)
2547                            && let Ok(modified) = metadata.modified()
2548                        {
2549                            let indexed_time = branch_info.last_indexed;
2550                            let file_time = modified
2551                                .duration_since(std::time::UNIX_EPOCH)
2552                                .unwrap_or_default()
2553                                .as_secs() as i64;
2554
2555                            if file_time > indexed_time {
2556                                // File modified after indexing - likely stale
2557                                // Note: We skip hash verification for performance (mtime check is sufficient)
2558                                changed += 1;
2559                            }
2560                        }
2561                    }
2562
2563                    if changed > 0 {
2564                        let warning = IndexWarning {
2565                            reason: format!("{} of {} sampled files modified", changed, checked),
2566                            action_required: "rfx index".to_string(),
2567                            files_modified: Some(changed as u32),
2568                            details: Some(IndexWarningDetails {
2569                                current_branch: Some(current_branch.clone()),
2570                                indexed_branch: Some(branch_info.branch.clone()),
2571                                current_commit: Some(current_commit.clone()),
2572                                indexed_commit: Some(branch_info.commit_sha.clone()),
2573                            }),
2574                        };
2575                        return Ok((IndexStatus::Stale, false, Some(warning)));
2576                    }
2577                }
2578
2579                // All checks passed - index is fresh
2580                return Ok((IndexStatus::Fresh, true, None));
2581            }
2582        }
2583
2584        // Not in a git repo or couldn't get git info - assume fresh
2585        Ok((IndexStatus::Fresh, true, None))
2586    }
2587
2588    /// Check index freshness and show non-blocking warnings
2589    ///
2590    /// This performs lightweight checks to warn users if their index might be stale:
2591    /// 1. Branch mismatch: indexed different branch
2592    /// 2. Commit changed: HEAD moved since indexing
2593    /// 3. File changes: quick mtime check on sample of files (if available)
2594    fn check_index_freshness(&self, filter: &QueryFilter) -> Result<()> {
2595        let root = self.cache.workspace_root();
2596
2597        // Check git state if in a git repo
2598        if crate::git::is_git_repo(&root) {
2599            if !crate::git::is_git_available() {
2600                static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
2601                if !filter.suppress_output {
2602                    WARNED.get_or_init(|| {
2603                        output::warn("⚠️  git binary not found in PATH; index freshness checks disabled for this session.");
2604                    });
2605                }
2606                return Ok(());
2607            }
2608            if let Ok(current_branch) = crate::git::get_current_branch(&root) {
2609                // Check if we're on a different branch than what was indexed
2610                if !self.cache.branch_exists(&current_branch).unwrap_or(false) {
2611                    if !filter.suppress_output {
2612                        output::warn(&format!(
2613                            "⚠️  WARNING: Index not found for branch '{}'. Run 'rfx index' to index this branch.",
2614                            current_branch
2615                        ));
2616                    }
2617                    return Ok(());
2618                }
2619
2620                // Branch exists - check if commit changed
2621                if let (Ok(current_commit), Ok(branch_info)) = (
2622                    crate::git::get_current_commit(&root),
2623                    self.cache.get_branch_info(&current_branch),
2624                ) {
2625                    if branch_info.commit_sha != current_commit {
2626                        if !filter.suppress_output {
2627                            output::warn(&format!(
2628                                "⚠️  WARNING: Index may be stale (commit changed: {} → {}). Consider running 'rfx index'.",
2629                                &branch_info.commit_sha[..7],
2630                                &current_commit[..7]
2631                            ));
2632                        }
2633                        return Ok(());
2634                    }
2635
2636                    // If commits match, do a quick file freshness check
2637                    // Sample up to 10 files to check for modifications (cheap mtime check)
2638                    if let Ok(branch_files) = self.cache.get_branch_files(&current_branch) {
2639                        let mut checked = 0;
2640                        let mut changed = 0;
2641                        const SAMPLE_SIZE: usize = 10;
2642
2643                        for (path, _indexed_hash) in branch_files.iter().take(SAMPLE_SIZE) {
2644                            checked += 1;
2645                            let file_path = std::path::Path::new(path);
2646
2647                            // Check if file exists and has been modified (mtime/size heuristic)
2648                            if let Ok(metadata) = std::fs::metadata(file_path)
2649                                && let Ok(modified) = metadata.modified()
2650                            {
2651                                let indexed_time = branch_info.last_indexed;
2652                                let file_time = modified
2653                                    .duration_since(std::time::UNIX_EPOCH)
2654                                    .unwrap_or_default()
2655                                    .as_secs()
2656                                    as i64;
2657
2658                                // If file modified after indexing, it might be stale
2659                                if file_time > indexed_time {
2660                                    // File modified after indexing - likely stale
2661                                    // Note: We skip hash verification for performance (mtime check is sufficient)
2662                                    // This may cause false positives if files were touched without changes,
2663                                    // but the warning is non-blocking and vastly better than slow queries
2664                                    changed += 1;
2665                                }
2666                            }
2667                        }
2668
2669                        if changed > 0 && !filter.suppress_output {
2670                            output::warn(&format!(
2671                                "⚠️  WARNING: {} of {} sampled files changed since indexing. Consider running 'rfx index'.",
2672                                changed, checked
2673                            ));
2674                        }
2675                    }
2676                }
2677            }
2678        }
2679
2680        Ok(())
2681    }
2682}
2683
2684/// Generate AI instruction based on query results
2685///
2686/// Provides context-aware guidance to AI agents on how to handle search results.
2687/// Uses priority-based logic to determine the most relevant instruction.
2688#[allow(clippy::too_many_arguments)]
2689pub fn generate_ai_instruction(
2690    result_count: usize,
2691    total_count: usize,
2692    has_more: bool,
2693    symbols_mode: bool,
2694    paths_only: bool,
2695    use_ast: bool,
2696    use_regex: bool,
2697    language_filter: bool,
2698    glob_filter: bool,
2699    exact_mode: bool,
2700) -> Option<String> {
2701    // Priority 1: No results
2702    if result_count == 0 {
2703        return Some(
2704            "No results found. Consider these alternatives: 1) Check pattern spelling, 2) Remove --kind or --lang filters to broaden search, 3) Try partial match or related term, 4) Use search_regex tool for pattern matching with special characters or complex patterns."
2705            .to_string()
2706        );
2707    }
2708
2709    // Priority 2: Query too broad (500+ results)
2710    if total_count >= 500 {
2711        return Some(format!(
2712            "Query too broad: {} results found. STOP. Do not list results. Refine search automatically by adding filters: kind parameter (Function/Struct/Class), lang parameter (rust/python/etc), or glob parameter (['src/**/*.rs']). Call search_code again with appropriate filters.",
2713            total_count
2714        ));
2715    }
2716
2717    // Priority 3: Paginated results
2718    //
2719    // REF-191: for autonomous find-all tasks this instruction must NOT tell the
2720    // agent to stop and ask the user — there is no user in an agent loop, and a
2721    // partial answer to "find every occurrence" is wrong. Instruct decisive
2722    // continuation: fetch the remaining page(s) via offset, or probe the total
2723    // cheaply with mode="count" first.
2724    if has_more {
2725        return Some(format!(
2726            "Showing {} of {} results — {} more available. This is a partial answer. To finish a find-all task, call again with offset={} (raise limit up to 500 to get the rest in one call), or use mode=\"count\" first if you only need the total.",
2727            result_count,
2728            total_count,
2729            total_count.saturating_sub(result_count),
2730            result_count
2731        ));
2732    }
2733
2734    // Priority 4: Single precise result (symbols mode)
2735    if result_count == 1 && symbols_mode {
2736        return Some(
2737            "Found 1 precise result. Respond concisely: '[symbol] at [path]:[line]'.".to_string(),
2738        );
2739    }
2740
2741    // Priority 5: Few precise results (symbols mode)
2742    if (2..=10).contains(&result_count) && symbols_mode {
2743        return Some(format!(
2744            "Found {} precise results (definitions only, not usages). List locations concisely: '[symbol] at [path]:[line]' for each result.",
2745            result_count
2746        ));
2747    }
2748
2749    // Priority 6: Many results (101-500)
2750    if (101..500).contains(&total_count) {
2751        return Some(format!(
2752            "Found {} results - this is broad. Suggest refining search with: kind parameter (Function/Struct/Class/etc), lang parameter (rust/python/etc), or glob parameter to narrow file scope.",
2753            total_count
2754        ));
2755    }
2756
2757    // Priority 7: Full-text mode with many results (suggest symbols mode)
2758    if result_count >= 100 && !symbols_mode {
2759        return Some(format!(
2760            "Found {} results in full-text search mode (includes definitions AND all usages). Consider using symbols=true parameter to filter to definitions only. This typically reduces results by 80-90%.",
2761            result_count
2762        ));
2763    }
2764
2765    // Priority 8: Paths-only mode
2766    if paths_only {
2767        return Some(format!(
2768            "Found {} unique files (paths-only mode - no code content included). Next step: Use Read tool on specific files that look relevant based on their paths.",
2769            result_count
2770        ));
2771    }
2772
2773    // Priority 9: AST query results
2774    if use_ast {
2775        return Some(format!(
2776            "Found {} results using AST pattern matching. These are structure-based matches using Tree-sitter patterns, not text search.",
2777            result_count
2778        ));
2779    }
2780
2781    // Priority 10: Regex with many results
2782    if use_regex && result_count >= 100 {
2783        return Some(format!(
2784            "Found {} results using regex pattern matching. Regex matches are expansive. Consider using exact text search or symbols mode for more precise results.",
2785            result_count
2786        ));
2787    }
2788
2789    // Priority 11: Language filter with few results
2790    if language_filter && result_count <= 5 {
2791        return Some(format!(
2792            "Found {} results with language filter active. Results are limited to this language only. Remove lang parameter if you want to search all languages.",
2793            result_count
2794        ));
2795    }
2796
2797    // Priority 12: Glob filter with few results
2798    if glob_filter && result_count <= 10 {
2799        return Some(format!(
2800            "Found {} results with glob filter active. Results are limited to matching paths. Remove glob parameter to search entire codebase.",
2801            result_count
2802        ));
2803    }
2804
2805    // Priority 13: Exact mode with few results
2806    if exact_mode && result_count <= 5 {
2807        return Some(format!(
2808            "Found {} results in exact match mode. Only exact symbol name matches are included. Remove exact parameter to allow substring matching.",
2809            result_count
2810        ));
2811    }
2812
2813    // Normal case (11-100 results, no special conditions) - no instruction
2814    None
2815}
2816
2817#[cfg(test)]
2818mod tests {
2819    use super::*;
2820    use crate::indexer::Indexer;
2821    use crate::models::IndexConfig;
2822    use std::fs;
2823    use tempfile::TempDir;
2824
2825    // ==================== Basic Tests ====================
2826
2827    #[test]
2828    fn test_query_engine_creation() {
2829        let temp = TempDir::new().unwrap();
2830        let cache = CacheManager::new(temp.path());
2831        let engine = QueryEngine::new(cache);
2832
2833        assert!(engine.cache.path().ends_with(".reflex"));
2834    }
2835
2836    #[test]
2837    fn test_filter_modes() {
2838        // Test that symbols_mode works as expected
2839        let filter_fulltext = QueryFilter::default();
2840        assert!(!filter_fulltext.symbols_mode);
2841
2842        let filter_symbols = QueryFilter {
2843            symbols_mode: true,
2844            ..Default::default()
2845        };
2846        assert!(filter_symbols.symbols_mode);
2847
2848        // Test that kind implies symbols_mode (handled in CLI layer)
2849        let filter_with_kind = QueryFilter {
2850            kind: Some(SymbolKind::Function),
2851            symbols_mode: true,
2852            ..Default::default()
2853        };
2854        assert!(filter_with_kind.symbols_mode);
2855    }
2856
2857    // ==================== Search Mode Tests ====================
2858
2859    #[test]
2860    fn test_fulltext_search() {
2861        let temp = TempDir::new().unwrap();
2862        let project = temp.path().join("project");
2863        fs::create_dir(&project).unwrap();
2864
2865        // Create test files
2866        fs::write(
2867            project.join("main.rs"),
2868            "fn main() {\n    println!(\"hello\");\n}",
2869        )
2870        .unwrap();
2871        fs::write(project.join("lib.rs"), "pub fn hello() {}").unwrap();
2872
2873        // Index the project
2874        let cache = CacheManager::new(&project);
2875        let indexer = Indexer::new(cache, IndexConfig::default());
2876        indexer.index(&project, false).unwrap();
2877
2878        // Search for "hello"
2879        let cache = CacheManager::new(&project);
2880        let engine = QueryEngine::new(cache);
2881        let filter = QueryFilter::default(); // full-text mode
2882        let results = engine.search("hello", filter).unwrap();
2883
2884        // Should find both occurrences (println and function name)
2885        assert!(results.len() >= 2);
2886        assert!(results.iter().any(|r| r.path.contains("main.rs")));
2887        assert!(results.iter().any(|r| r.path.contains("lib.rs")));
2888    }
2889
2890    #[test]
2891    fn test_symbol_search() {
2892        let temp = TempDir::new().unwrap();
2893        let project = temp.path().join("project");
2894        fs::create_dir(&project).unwrap();
2895
2896        // Create test file with function definition and call
2897        fs::write(
2898            project.join("main.rs"),
2899            "fn greet() {}\nfn main() {\n    greet();\n}",
2900        )
2901        .unwrap();
2902
2903        // Index
2904        let cache = CacheManager::new(&project);
2905        let indexer = Indexer::new(cache, IndexConfig::default());
2906        indexer.index(&project, false).unwrap();
2907
2908        let cache = CacheManager::new(&project);
2909
2910        // Symbol search (definitions only)
2911        let engine = QueryEngine::new(cache);
2912        let filter = QueryFilter {
2913            symbols_mode: true,
2914            ..Default::default()
2915        };
2916        let results = engine.search("greet", filter).unwrap();
2917
2918        // Should find only the definition, not the call
2919        assert!(!results.is_empty());
2920        assert!(results.iter().any(|r| r.kind == SymbolKind::Function));
2921    }
2922
2923    #[test]
2924    fn test_regex_search() {
2925        let temp = TempDir::new().unwrap();
2926        let project = temp.path().join("project");
2927        fs::create_dir(&project).unwrap();
2928
2929        fs::write(
2930            project.join("main.rs"),
2931            "fn test1() {}\nfn test2() {}\nfn other() {}",
2932        )
2933        .unwrap();
2934
2935        let cache = CacheManager::new(&project);
2936        let indexer = Indexer::new(cache, IndexConfig::default());
2937        indexer.index(&project, false).unwrap();
2938
2939        let cache = CacheManager::new(&project);
2940
2941        let engine = QueryEngine::new(cache);
2942        let filter = QueryFilter {
2943            use_regex: true,
2944            ..Default::default()
2945        };
2946        let results = engine.search(r"fn test\d", filter).unwrap();
2947
2948        // Should match test1 and test2 but not other
2949        assert_eq!(results.len(), 2);
2950        assert!(results.iter().all(|r| r.preview.contains("test")));
2951    }
2952
2953    // ==================== Filter Tests ====================
2954
2955    #[test]
2956    fn test_language_filter() {
2957        let temp = TempDir::new().unwrap();
2958        let project = temp.path().join("project");
2959        fs::create_dir(&project).unwrap();
2960
2961        fs::write(project.join("main.rs"), "fn main() {}").unwrap();
2962        fs::write(project.join("main.js"), "function main() {}").unwrap();
2963
2964        let cache = CacheManager::new(&project);
2965        let indexer = Indexer::new(cache, IndexConfig::default());
2966        indexer.index(&project, false).unwrap();
2967
2968        let cache = CacheManager::new(&project);
2969
2970        let engine = QueryEngine::new(cache);
2971
2972        // Filter to Rust only
2973        let filter = QueryFilter {
2974            language: Some(Language::Rust),
2975            ..Default::default()
2976        };
2977        let results = engine.search("main", filter).unwrap();
2978
2979        assert!(results.iter().all(|r| r.lang == Language::Rust));
2980        assert!(results.iter().all(|r| r.path.ends_with(".rs")));
2981    }
2982
2983    #[test]
2984    fn test_kind_filter() {
2985        let temp = TempDir::new().unwrap();
2986        let project = temp.path().join("project");
2987        fs::create_dir(&project).unwrap();
2988
2989        fs::write(
2990            project.join("main.rs"),
2991            "struct Point {}\nfn main() {}\nimpl Point { fn new() {} }",
2992        )
2993        .unwrap();
2994
2995        let cache = CacheManager::new(&project);
2996        let indexer = Indexer::new(cache, IndexConfig::default());
2997        indexer.index(&project, false).unwrap();
2998
2999        let cache = CacheManager::new(&project);
3000
3001        let engine = QueryEngine::new(cache);
3002
3003        // Filter to functions only (includes methods)
3004        let filter = QueryFilter {
3005            symbols_mode: true,
3006            kind: Some(SymbolKind::Function),
3007            use_contains: true, // "mai" is substring of "main"
3008            ..Default::default()
3009        };
3010        // Search for "mai" which should match "main" (tri gram pattern will def be in index)
3011        let results = engine.search("mai", filter).unwrap();
3012
3013        // Should find main function
3014        assert!(!results.is_empty(), "Should find at least one result");
3015        assert!(
3016            results.iter().any(|r| r.symbol.as_deref() == Some("main")),
3017            "Should find 'main' function"
3018        );
3019    }
3020
3021    #[test]
3022    fn test_file_pattern_filter() {
3023        let temp = TempDir::new().unwrap();
3024        let project = temp.path().join("project");
3025        fs::create_dir_all(project.join("src")).unwrap();
3026        fs::create_dir_all(project.join("tests")).unwrap();
3027
3028        fs::write(project.join("src/lib.rs"), "fn foo() {}").unwrap();
3029        fs::write(project.join("tests/test.rs"), "fn foo() {}").unwrap();
3030
3031        let cache = CacheManager::new(&project);
3032        let indexer = Indexer::new(cache, IndexConfig::default());
3033        indexer.index(&project, false).unwrap();
3034
3035        let cache = CacheManager::new(&project);
3036
3037        let engine = QueryEngine::new(cache);
3038
3039        // Filter to src/ only
3040        let filter = QueryFilter {
3041            file_pattern: Some("src/".to_string()),
3042            ..Default::default()
3043        };
3044        let results = engine.search("foo", filter).unwrap();
3045
3046        assert!(results.iter().all(|r| r.path.contains("src/")));
3047        assert!(!results.iter().any(|r| r.path.contains("tests/")));
3048    }
3049
3050    #[test]
3051    fn test_limit_filter() {
3052        let temp = TempDir::new().unwrap();
3053        let project = temp.path().join("project");
3054        fs::create_dir(&project).unwrap();
3055
3056        // Create file with many matches
3057        let content = (0..20)
3058            .map(|i| format!("fn test{}() {{}}", i))
3059            .collect::<Vec<_>>()
3060            .join("\n");
3061        fs::write(project.join("main.rs"), content).unwrap();
3062
3063        let cache = CacheManager::new(&project);
3064        let indexer = Indexer::new(cache, IndexConfig::default());
3065        indexer.index(&project, false).unwrap();
3066
3067        let cache = CacheManager::new(&project);
3068
3069        let engine = QueryEngine::new(cache);
3070
3071        // Limit to 5 results
3072        let filter = QueryFilter {
3073            limit: Some(5),
3074            use_contains: true, // "test" is substring of "test0", "test1", etc.
3075            ..Default::default()
3076        };
3077        let results = engine.search("test", filter).unwrap();
3078
3079        assert_eq!(results.len(), 5);
3080    }
3081
3082    #[test]
3083    fn test_exact_match_filter() {
3084        let temp = TempDir::new().unwrap();
3085        let project = temp.path().join("project");
3086        fs::create_dir(&project).unwrap();
3087
3088        fs::write(
3089            project.join("main.rs"),
3090            "fn test() {}\nfn test_helper() {}\nfn other_test() {}",
3091        )
3092        .unwrap();
3093
3094        let cache = CacheManager::new(&project);
3095        let indexer = Indexer::new(cache, IndexConfig::default());
3096        indexer.index(&project, false).unwrap();
3097
3098        let cache = CacheManager::new(&project);
3099
3100        let engine = QueryEngine::new(cache);
3101
3102        // Exact match for "test"
3103        let filter = QueryFilter {
3104            symbols_mode: true,
3105            exact: true,
3106            ..Default::default()
3107        };
3108        let results = engine.search("test", filter).unwrap();
3109
3110        // Should only match exactly "test", not "test_helper" or "other_test"
3111        assert_eq!(results.len(), 1);
3112        assert_eq!(results[0].symbol.as_deref(), Some("test"));
3113    }
3114
3115    // ==================== Expand Mode Tests ====================
3116
3117    #[test]
3118    fn test_expand_mode() {
3119        let temp = TempDir::new().unwrap();
3120        let project = temp.path().join("project");
3121        fs::create_dir(&project).unwrap();
3122
3123        fs::write(
3124            project.join("main.rs"),
3125            "fn greet() {\n    println!(\"Hello\");\n    println!(\"World\");\n}",
3126        )
3127        .unwrap();
3128
3129        let cache = CacheManager::new(&project);
3130        let indexer = Indexer::new(cache, IndexConfig::default());
3131        indexer.index(&project, false).unwrap();
3132
3133        let cache = CacheManager::new(&project);
3134
3135        let engine = QueryEngine::new(cache);
3136
3137        // Search with expand mode
3138        let filter = QueryFilter {
3139            symbols_mode: true,
3140            expand: true,
3141            ..Default::default()
3142        };
3143        let results = engine.search("greet", filter).unwrap();
3144
3145        // Should have full function body in preview
3146        assert!(!results.is_empty());
3147        let result = &results[0];
3148        assert!(result.preview.contains("println"));
3149    }
3150
3151    // ==================== Edge Cases ====================
3152
3153    #[test]
3154    fn test_search_empty_index() {
3155        let temp = TempDir::new().unwrap();
3156        let project = temp.path().join("project");
3157        fs::create_dir(&project).unwrap();
3158
3159        let cache = CacheManager::new(&project);
3160        let indexer = Indexer::new(cache, IndexConfig::default());
3161        indexer.index(&project, false).unwrap();
3162
3163        let cache = CacheManager::new(&project);
3164
3165        let engine = QueryEngine::new(cache);
3166        let filter = QueryFilter::default();
3167        let results = engine.search("nonexistent", filter).unwrap();
3168
3169        assert_eq!(results.len(), 0);
3170    }
3171
3172    #[test]
3173    fn test_search_no_index() {
3174        let temp = TempDir::new().unwrap();
3175        let project = temp.path().join("project");
3176        fs::create_dir(&project).unwrap();
3177
3178        let cache = CacheManager::new(&project);
3179        let engine = QueryEngine::new(cache);
3180        let filter = QueryFilter::default();
3181
3182        // Should fail when index doesn't exist
3183        assert!(engine.search("test", filter).is_err());
3184    }
3185
3186    #[test]
3187    fn test_search_special_characters() {
3188        let temp = TempDir::new().unwrap();
3189        let project = temp.path().join("project");
3190        fs::create_dir(&project).unwrap();
3191
3192        fs::write(project.join("main.rs"), "let x = 42;\nlet y = x + 1;").unwrap();
3193
3194        let cache = CacheManager::new(&project);
3195        let indexer = Indexer::new(cache, IndexConfig::default());
3196        indexer.index(&project, false).unwrap();
3197
3198        let cache = CacheManager::new(&project);
3199
3200        let engine = QueryEngine::new(cache);
3201        let filter = QueryFilter::default();
3202
3203        // Search for special characters
3204        let results = engine.search("x + ", filter).unwrap();
3205        assert!(!results.is_empty());
3206    }
3207
3208    #[test]
3209    fn test_search_unicode() {
3210        let temp = TempDir::new().unwrap();
3211        let project = temp.path().join("project");
3212        fs::create_dir(&project).unwrap();
3213
3214        fs::write(project.join("main.rs"), "// 你好世界\nfn main() {}").unwrap();
3215
3216        let cache = CacheManager::new(&project);
3217        let indexer = Indexer::new(cache, IndexConfig::default());
3218        indexer.index(&project, false).unwrap();
3219
3220        let cache = CacheManager::new(&project);
3221
3222        let engine = QueryEngine::new(cache);
3223        let filter = QueryFilter {
3224            use_contains: true, // Unicode word boundaries may not work as expected
3225            force: true,        // Bypass broad query detection for 2-char Unicode pattern
3226            ..Default::default()
3227        };
3228
3229        // Search for unicode characters
3230        let results = engine.search("你好", filter).unwrap();
3231        assert!(!results.is_empty());
3232    }
3233
3234    #[test]
3235    fn test_case_sensitive_search() {
3236        let temp = TempDir::new().unwrap();
3237        let project = temp.path().join("project");
3238        fs::create_dir(&project).unwrap();
3239
3240        fs::write(project.join("main.rs"), "fn Test() {}\nfn test() {}").unwrap();
3241
3242        let cache = CacheManager::new(&project);
3243        let indexer = Indexer::new(cache, IndexConfig::default());
3244        indexer.index(&project, false).unwrap();
3245
3246        let cache = CacheManager::new(&project);
3247
3248        let engine = QueryEngine::new(cache);
3249        let filter = QueryFilter::default();
3250
3251        // Search is case-sensitive
3252        let results = engine.search("Test", filter).unwrap();
3253        assert!(results.iter().any(|r| r.preview.contains("Test()")));
3254    }
3255
3256    // ==================== Determinism Tests ====================
3257
3258    #[test]
3259    fn test_results_sorted_deterministically() {
3260        let temp = TempDir::new().unwrap();
3261        let project = temp.path().join("project");
3262        fs::create_dir(&project).unwrap();
3263
3264        fs::write(project.join("a.rs"), "fn test() {}").unwrap();
3265        fs::write(project.join("z.rs"), "fn test() {}").unwrap();
3266        fs::write(project.join("m.rs"), "fn test() {}\nfn test2() {}").unwrap();
3267
3268        let cache = CacheManager::new(&project);
3269        let indexer = Indexer::new(cache, IndexConfig::default());
3270        indexer.index(&project, false).unwrap();
3271
3272        let cache = CacheManager::new(&project);
3273
3274        let engine = QueryEngine::new(cache);
3275        let filter = QueryFilter::default();
3276
3277        // Run search multiple times
3278        let results1 = engine.search("test", filter.clone()).unwrap();
3279        let results2 = engine.search("test", filter.clone()).unwrap();
3280        let results3 = engine.search("test", filter).unwrap();
3281
3282        // Results should be identical and sorted by path then line
3283        assert_eq!(results1.len(), results2.len());
3284        assert_eq!(results1.len(), results3.len());
3285
3286        for i in 0..results1.len() {
3287            assert_eq!(results1[i].path, results2[i].path);
3288            assert_eq!(results1[i].path, results3[i].path);
3289            assert_eq!(results1[i].span.start_line, results2[i].span.start_line);
3290            assert_eq!(results1[i].span.start_line, results3[i].span.start_line);
3291        }
3292
3293        // Verify sorting (path ascending, then line ascending)
3294        for i in 0..results1.len().saturating_sub(1) {
3295            let curr = &results1[i];
3296            let next = &results1[i + 1];
3297            assert!(
3298                curr.path < next.path
3299                    || (curr.path == next.path && curr.span.start_line <= next.span.start_line)
3300            );
3301        }
3302    }
3303
3304    // ==================== Combined Filter Tests ====================
3305
3306    #[test]
3307    fn test_multiple_filters_combined() {
3308        let temp = TempDir::new().unwrap();
3309        let project = temp.path().join("project");
3310        fs::create_dir_all(project.join("src")).unwrap();
3311
3312        fs::write(project.join("src/main.rs"), "fn test() {}\nstruct Test {}").unwrap();
3313        fs::write(project.join("src/lib.rs"), "fn test() {}").unwrap();
3314        fs::write(project.join("test.js"), "function test() {}").unwrap();
3315
3316        let cache = CacheManager::new(&project);
3317        let indexer = Indexer::new(cache, IndexConfig::default());
3318        indexer.index(&project, false).unwrap();
3319
3320        let cache = CacheManager::new(&project);
3321
3322        let engine = QueryEngine::new(cache);
3323
3324        // Combine language, kind, and file pattern filters
3325        let filter = QueryFilter {
3326            language: Some(Language::Rust),
3327            kind: Some(SymbolKind::Function),
3328            file_pattern: Some("src/main".to_string()),
3329            symbols_mode: true,
3330            ..Default::default()
3331        };
3332        let results = engine.search("test", filter).unwrap();
3333
3334        // Should only find the function in src/main.rs
3335        assert_eq!(results.len(), 1);
3336        assert!(results[0].path.contains("src/main.rs"));
3337        assert_eq!(results[0].kind, SymbolKind::Function);
3338    }
3339
3340    // ==================== Helper Method Tests ====================
3341
3342    #[test]
3343    fn test_find_symbol_helper() {
3344        let temp = TempDir::new().unwrap();
3345        let project = temp.path().join("project");
3346        fs::create_dir(&project).unwrap();
3347
3348        fs::write(project.join("main.rs"), "fn greet() {}").unwrap();
3349
3350        let cache = CacheManager::new(&project);
3351        let indexer = Indexer::new(cache, IndexConfig::default());
3352        indexer.index(&project, false).unwrap();
3353
3354        let cache = CacheManager::new(&project);
3355
3356        let engine = QueryEngine::new(cache);
3357        let results = engine.find_symbol("greet").unwrap();
3358
3359        assert!(!results.is_empty());
3360        assert_eq!(results[0].kind, SymbolKind::Function);
3361    }
3362
3363    #[test]
3364    fn test_list_by_kind_helper() {
3365        let temp = TempDir::new().unwrap();
3366        let project = temp.path().join("project");
3367        fs::create_dir(&project).unwrap();
3368
3369        fs::write(
3370            project.join("main.rs"),
3371            "struct Point {}\nfn test() {}\nstruct Line {}",
3372        )
3373        .unwrap();
3374
3375        let cache = CacheManager::new(&project);
3376        let indexer = Indexer::new(cache, IndexConfig::default());
3377        indexer.index(&project, false).unwrap();
3378
3379        let cache = CacheManager::new(&project);
3380
3381        let engine = QueryEngine::new(cache);
3382
3383        // Search for structs that contain "oin" (Point contains it, Line doesn't)
3384        let filter = QueryFilter {
3385            kind: Some(SymbolKind::Struct),
3386            symbols_mode: true,
3387            use_contains: true, // "oin" is substring of "Point"
3388            ..Default::default()
3389        };
3390        let results = engine.search("oin", filter).unwrap();
3391
3392        // Should find Point struct
3393        assert!(!results.is_empty(), "Should find at least Point struct");
3394        assert!(results.iter().all(|r| r.kind == SymbolKind::Struct));
3395        assert!(results.iter().any(|r| r.symbol.as_deref() == Some("Point")));
3396    }
3397
3398    // ==================== Metadata Tests ====================
3399
3400    #[test]
3401    fn test_search_with_metadata() {
3402        let temp = TempDir::new().unwrap();
3403        let project = temp.path().join("project");
3404        fs::create_dir(&project).unwrap();
3405
3406        fs::write(project.join("main.rs"), "fn test() {}").unwrap();
3407
3408        let cache = CacheManager::new(&project);
3409        let indexer = Indexer::new(cache, IndexConfig::default());
3410        indexer.index(&project, false).unwrap();
3411
3412        let cache = CacheManager::new(&project);
3413
3414        let engine = QueryEngine::new(cache);
3415        let filter = QueryFilter::default();
3416        let response = engine.search_with_metadata("test", filter).unwrap();
3417
3418        // Check metadata is present (status might be stale if run inside git repo)
3419        assert!(!response.results.is_empty());
3420        // Note: can_trust_results may be false if running in a git repo without branch index
3421    }
3422
3423    // ==================== Multi-language Tests ====================
3424
3425    #[test]
3426    fn test_search_across_languages() {
3427        let temp = TempDir::new().unwrap();
3428        let project = temp.path().join("project");
3429        fs::create_dir(&project).unwrap();
3430
3431        fs::write(project.join("main.rs"), "fn greet() {}").unwrap();
3432        fs::write(project.join("main.ts"), "function greet() {}").unwrap();
3433        fs::write(project.join("main.py"), "def greet(): pass").unwrap();
3434
3435        let cache = CacheManager::new(&project);
3436        let indexer = Indexer::new(cache, IndexConfig::default());
3437        indexer.index(&project, false).unwrap();
3438
3439        let cache = CacheManager::new(&project);
3440
3441        let engine = QueryEngine::new(cache);
3442        let filter = QueryFilter::default();
3443        let results = engine.search("greet", filter).unwrap();
3444
3445        // Should find greet in all three languages
3446        assert!(results.len() >= 3);
3447        assert!(results.iter().any(|r| r.lang == Language::Rust));
3448        assert!(results.iter().any(|r| r.lang == Language::TypeScript));
3449        assert!(results.iter().any(|r| r.lang == Language::Python));
3450    }
3451}