Skip to main content

reflex/cli/
query.rs

1use crate::cache::CacheManager;
2use crate::models::Language;
3use crate::query::{QueryEngine, QueryFilter};
4use anyhow::Result;
5use owo_colors::OwoColorize;
6use std::time::Instant;
7
8/// Smart truncate preview to reduce token usage
9/// Truncates at word boundary if possible, adds ellipsis if truncated
10pub fn truncate_preview(preview: &str, max_length: usize) -> String {
11    if preview.len() <= max_length {
12        return preview.to_string();
13    }
14
15    // Find a good break point (prefer word boundary)
16    let truncate_at = preview
17        .char_indices()
18        .take(max_length)
19        .filter(|(_, c)| c.is_whitespace())
20        .last()
21        .map(|(i, _)| i)
22        // No whitespace in the first `max_length` chars — i.e. minified code. The
23        // fallback used to be a raw BYTE index, and slicing there panics whenever it
24        // lands mid-codepoint. Minified bundles carry emoji and CJK in embedded i18n
25        // tables, so this was a live crash in `rfx query` and the MCP server.
26        .unwrap_or_else(|| {
27            let cap = max_length.min(preview.len());
28            (0..=cap)
29                .rev()
30                .find(|&i| preview.is_char_boundary(i))
31                .unwrap_or(0)
32        });
33
34    let mut truncated = preview[..truncate_at].to_string();
35    truncated.push('…');
36    truncated
37}
38
39/// Handle the `query` subcommand
40#[allow(clippy::too_many_arguments)]
41pub(super) fn handle_query(
42    pattern: String,
43    symbols_flag: bool,
44    lang: Option<String>,
45    kind_str: Option<String>,
46    use_ast: bool,
47    use_regex: bool,
48    as_json: bool,
49    pretty_json: bool,
50    ai_mode: bool,
51    limit: Option<usize>,
52    offset: Option<usize>,
53    expand: bool,
54    file_pattern: Option<String>,
55    exact: bool,
56    use_contains: bool,
57    count_only: bool,
58    timeout_secs: u64,
59    plain: bool,
60    glob_patterns: Vec<String>,
61    exclude_patterns: Vec<String>,
62    paths_only: bool,
63    no_truncate: bool,
64    context_arg: Option<usize>,
65    all: bool,
66    force: bool,
67    include_dependencies: bool,
68) -> Result<()> {
69    log::info!("Starting query command");
70
71    // AI mode implies JSON output
72    let as_json = as_json || ai_mode;
73
74    let cache = CacheManager::new(".");
75    let engine = QueryEngine::new(cache);
76
77    // Parse and validate language filter
78    let language = if let Some(lang_str) = lang.as_deref() {
79        match Language::from_name(lang_str) {
80            Some(l) => Some(l),
81            None => anyhow::bail!(
82                "Unknown language: '{}'\n\nSupported languages:\n  {}\n\nExample: rfx query \"pattern\" --lang rust",
83                lang_str,
84                Language::supported_names_help()
85            ),
86        }
87    } else {
88        None
89    };
90
91    // Warn when Swift is requested — symbol queries will return no results
92    if language == Some(Language::Swift) {
93        eprintln!(
94            "{}: Swift symbol extraction is temporarily disabled (tree-sitter-swift 0.7.x grammar incompatibility). \
95Full-text search will still work, but --symbols queries will return no results.",
96            "Warning".yellow().bold()
97        );
98    }
99
100    // Warn when --dependencies is used with a non-Rust language filter (REF-171)
101    if include_dependencies && matches!(language, Some(l) if l != Language::Rust) {
102        eprintln!(
103            "{}: --dependencies is currently only supported for Rust files. \
104No dependency data will be included for {} files.",
105            "Warning".yellow().bold(),
106            lang.as_deref().unwrap_or("non-Rust")
107        );
108    }
109
110    // Parse and validate symbol kind — error on unrecognised values (REF-60)
111    let kind = if let Some(s) = kind_str.as_deref() {
112        let capitalized = {
113            let mut chars = s.chars();
114            match chars.next() {
115                None => String::new(),
116                Some(first) => first
117                    .to_uppercase()
118                    .chain(chars.flat_map(|c| c.to_lowercase()))
119                    .collect(),
120            }
121        };
122        let parsed = capitalized
123            .parse::<crate::models::SymbolKind>()
124            .unwrap_or(crate::models::SymbolKind::Unknown(s.to_string()));
125        if let crate::models::SymbolKind::Unknown(_) = &parsed {
126            anyhow::bail!(
127                "Unknown symbol kind: '{}'\n\nSupported kinds:\n  function, class, struct, enum, interface, trait, \
128                constant, variable, method, module, namespace, type, macro, property, event, import, export, attribute\n\n\
129                Example: rfx query \"parse\" --kind function",
130                s
131            );
132        }
133        Some(parsed)
134    } else {
135        None
136    };
137
138    // Smart behavior: --kind implies --symbols
139    let symbols_mode = symbols_flag || kind.is_some();
140
141    // --limit 0 is rejected; use --all for unlimited results
142    if limit == Some(0) {
143        anyhow::bail!(
144            "--limit 0 is not valid. To return all results use --all (or -a).\n\
145             To return exactly 0 results is meaningless; omit --limit to use the default of 100."
146        );
147    }
148
149    // Smart limit handling:
150    // 1. If --count is set: no limit (count should always show total)
151    // 2. If --all is set: no limit (None)
152    // 3. If --paths is set and user didn't specify --limit: no limit (None)
153    // 4. If user specified --limit: use that value
154    // 5. Otherwise: use default limit of 100
155    let final_limit = if count_only || all || (paths_only && limit.is_none()) {
156        None // --count, --all, and --paths (without explicit --limit) all remove the result limit
157    } else if let Some(user_limit) = limit {
158        Some(user_limit) // Use user-specified limit
159    } else {
160        Some(100) // Default: limit to 100 results for token efficiency
161    };
162
163    // Validate AST query requirements
164    if use_ast && language.is_none() {
165        anyhow::bail!(
166            "AST pattern matching requires a language to be specified.\n\
167             \n\
168             Use --lang to specify the language for tree-sitter parsing.\n\
169             \n\
170             Supported languages for AST queries:\n\
171             • rust, python, go, java, c, c++, c#, php, ruby, kotlin, zig, typescript, javascript\n\
172             \n\
173             Note: Vue and Svelte use line-based parsing and do not support AST queries.\n\
174             \n\
175             WARNING: AST queries are SLOW (500ms-2s+). Use --symbols instead for 95% of cases.\n\
176             \n\
177             Examples:\n\
178             • rfx query \"(function_definition) @fn\" --ast --lang python\n\
179             • rfx query \"(class_declaration) @class\" --ast --lang typescript --glob \"src/**/*.ts\""
180        );
181    }
182
183    // VALIDATION: Check for conflicting or problematic flag combinations
184    // Only show warnings/errors in non-JSON mode (avoid breaking parsers)
185    if !as_json {
186        let mut has_errors = false;
187
188        // ERROR: Mutually exclusive pattern matching modes
189        if use_regex && use_contains {
190            eprintln!(
191                "{}",
192                "ERROR: Cannot use --regex and --contains together."
193                    .red()
194                    .bold()
195            );
196            eprintln!(
197                "  {} --regex for pattern matching (alternation, wildcards, etc.)",
198                "•".dimmed()
199            );
200            eprintln!(
201                "  {} --contains for substring matching (expansive search)",
202                "•".dimmed()
203            );
204            eprintln!(
205                "\n  {} Choose one based on your needs:",
206                "Tip:".cyan().bold()
207            );
208            eprintln!("    {} for OR logic: --regex", "pattern1|pattern2".yellow());
209            eprintln!("    {} for substring: --contains", "partial_text".yellow());
210            has_errors = true;
211        }
212
213        // ERROR: Contradictory matching requirements
214        if exact && use_contains {
215            eprintln!(
216                "{}",
217                "ERROR: Cannot use --exact and --contains together (contradictory)."
218                    .red()
219                    .bold()
220            );
221            eprintln!(
222                "  {} --exact requires exact symbol name match",
223                "•".dimmed()
224            );
225            eprintln!("  {} --contains allows substring matching", "•".dimmed());
226            has_errors = true;
227        }
228
229        // WARNING: Redundant file filtering
230        if file_pattern.is_some() && !glob_patterns.is_empty() {
231            eprintln!(
232                "{}",
233                "WARNING: Both --file and --glob specified.".yellow().bold()
234            );
235            eprintln!(
236                "  {} --file does substring matching on file paths",
237                "•".dimmed()
238            );
239            eprintln!(
240                "  {} --glob does pattern matching with wildcards",
241                "•".dimmed()
242            );
243            eprintln!(
244                "  {} Both filters will apply (AND condition)",
245                "Note:".dimmed()
246            );
247            eprintln!("\n  {} Usually you only need one:", "Tip:".cyan().bold());
248            eprintln!("    {} for simple matching", "--file User.php".yellow());
249            eprintln!(
250                "    {} for pattern matching",
251                "--glob src/**/*.php".yellow()
252            );
253        }
254
255        // INFO: Detect potentially problematic glob patterns
256        for pattern in &glob_patterns {
257            // Check for literal quotes in pattern
258            if (pattern.starts_with('\'') && pattern.ends_with('\''))
259                || (pattern.starts_with('"') && pattern.ends_with('"'))
260            {
261                eprintln!(
262                    "{}",
263                    format!("WARNING: Glob pattern contains quotes: {}", pattern)
264                        .yellow()
265                        .bold()
266                );
267                eprintln!(
268                    "  {} Shell quotes should not be part of the pattern",
269                    "Note:".dimmed()
270                );
271                eprintln!("  {} --glob src/**/*.rs", "Correct:".green());
272                eprintln!("  {} --glob 'src/**/*.rs'", "Wrong:".red().dimmed());
273            }
274
275            // Suggest using ** instead of * for recursive matching
276            if pattern.contains("*/") && !pattern.contains("**/") {
277                eprintln!(
278                    "{}",
279                    format!(
280                        "INFO: Glob '{}' uses * (matches one directory level)",
281                        pattern
282                    )
283                    .cyan()
284                );
285                eprintln!(
286                    "  {} Use ** for recursive matching across subdirectories",
287                    "Tip:".cyan().bold()
288                );
289                eprintln!(
290                    "    {} → matches files in Models/ only",
291                    "app/Models/*.php".yellow()
292                );
293                eprintln!(
294                    "    {} → matches files in Models/ and subdirs",
295                    "app/Models/**/*.php".green()
296                );
297            }
298        }
299
300        if has_errors {
301            anyhow::bail!("Invalid flag combination. Fix the errors above and try again.");
302        }
303
304        // REF-58: Warn when --file looks like a concrete path that doesn't exist on disk
305        if let Some(ref fp) = file_pattern {
306            // Only warn when it looks like a literal path (no glob wildcards, has extension or slash)
307            let looks_like_path =
308                !fp.contains('*') && !fp.contains('?') && (fp.contains('/') || fp.contains('.'));
309            if looks_like_path && !std::path::Path::new(fp).exists() {
310                eprintln!(
311                    "{}",
312                    format!("[warn] --file path not found on disk: {}", fp).yellow()
313                );
314                eprintln!(
315                    "  Continuing with substring match — results will be empty if no indexed path contains '{}'.",
316                    fp
317                );
318            }
319        }
320    }
321
322    // Clamp context lines to a sane max (10) to avoid huge output
323    let context_lines = context_arg.map(|n| n.min(10)).unwrap_or(0);
324
325    let filter = QueryFilter {
326        language,
327        kind,
328        use_ast,
329        use_regex,
330        limit: final_limit,
331        symbols_mode,
332        expand,
333        file_pattern,
334        exact,
335        use_contains,
336        timeout_secs,
337        glob_patterns: glob_patterns.clone(),
338        exclude_patterns,
339        paths_only,
340        offset,
341        force,
342        suppress_output: as_json, // Suppress warnings in JSON mode
343        include_dependencies,
344        context_lines,
345        ..Default::default()
346    };
347
348    // Measure query time
349    let start = Instant::now();
350
351    // Execute query and get pagination metadata
352    // Handle errors specially for JSON output mode
353    let (query_response, mut flat_results, total_results, has_more) = if use_ast {
354        // AST query: pattern is the S-expression, scan all files
355        match engine.search_ast_all_files(&pattern, filter.clone()) {
356            Ok(ast_results) => {
357                let count = ast_results.len();
358                (None, ast_results, count, false)
359            }
360            Err(e) => {
361                if as_json {
362                    // Output error as JSON
363                    let error_response = serde_json::json!({
364                        "error": e.to_string(),
365                        "query_too_broad": e.to_string().contains("Query too broad")
366                    });
367                    let json_output = if pretty_json {
368                        serde_json::to_string_pretty(&error_response)?
369                    } else {
370                        serde_json::to_string(&error_response)?
371                    };
372                    println!("{}", json_output);
373                    std::process::exit(1);
374                } else {
375                    return Err(e);
376                }
377            }
378        }
379    } else {
380        // Use metadata-aware search for all queries (to get pagination info)
381        match engine.search_with_metadata(&pattern, filter.clone()) {
382            Ok(response) => {
383                let total = response.pagination.total;
384                let has_more = response.pagination.has_more;
385
386                // Flatten grouped results to SearchResult vec for plain text formatting
387                let flat = response
388                    .results
389                    .iter()
390                    .flat_map(|file_group| {
391                        file_group.matches.iter().map(move |m| {
392                            crate::models::SearchResult {
393                                path: file_group.path.clone(),
394                                lang: crate::models::Language::Unknown, // Will be set by formatter if needed
395                                kind: m.kind.clone(),
396                                symbol: m.symbol.clone(),
397                                span: m.span.clone(),
398                                preview: m.preview.clone(),
399                                dependencies: file_group.dependencies.clone(),
400                            }
401                        })
402                    })
403                    .collect();
404
405                (Some(response), flat, total, has_more)
406            }
407            Err(e) => {
408                if as_json {
409                    // Output error as JSON
410                    let error_response = serde_json::json!({
411                        "error": e.to_string(),
412                        "query_too_broad": e.to_string().contains("Query too broad")
413                    });
414                    let json_output = if pretty_json {
415                        serde_json::to_string_pretty(&error_response)?
416                    } else {
417                        serde_json::to_string(&error_response)?
418                    };
419                    println!("{}", json_output);
420                    std::process::exit(1);
421                } else {
422                    return Err(e);
423                }
424            }
425        }
426    };
427
428    // Apply preview truncation unless --no-truncate is set
429    if !no_truncate {
430        const MAX_PREVIEW_LENGTH: usize = 100;
431        for result in &mut flat_results {
432            result.preview = truncate_preview(&result.preview, MAX_PREVIEW_LENGTH);
433        }
434    }
435
436    let elapsed = start.elapsed();
437
438    // Format timing string
439    let timing_str = if elapsed.as_millis() < 1 {
440        format!("{:.1}ms", elapsed.as_secs_f64() * 1000.0)
441    } else {
442        format!("{}ms", elapsed.as_millis())
443    };
444
445    if as_json {
446        if count_only {
447            // Count-only JSON mode: output simple count object
448            let count_response = serde_json::json!({
449                "count": total_results,
450                "timing_ms": elapsed.as_millis()
451            });
452            let json_output = if pretty_json {
453                serde_json::to_string_pretty(&count_response)?
454            } else {
455                serde_json::to_string(&count_response)?
456            };
457            println!("{}", json_output);
458        } else if paths_only {
459            // Paths-only JSON mode: output deduplicated array of path strings (REF-62)
460            let mut seen = std::collections::HashSet::new();
461            let unique_paths: Vec<String> = flat_results
462                .iter()
463                .filter_map(|r| {
464                    if seen.insert(r.path.clone()) {
465                        Some(r.path.clone())
466                    } else {
467                        None
468                    }
469                })
470                .collect();
471
472            let json_output = if ai_mode {
473                // REF-59: wrap with ai_instruction when --ai flag is used
474                let ai_instruction = crate::query::generate_ai_instruction(
475                    unique_paths.len(),
476                    total_results,
477                    has_more,
478                    symbols_mode,
479                    true,
480                    use_ast,
481                    use_regex,
482                    language.is_some(),
483                    !glob_patterns.is_empty(),
484                    exact,
485                );
486                let wrapper = serde_json::json!({
487                    "ai_instruction": ai_instruction,
488                    "count": unique_paths.len(),
489                    "results": unique_paths,
490                });
491                if pretty_json {
492                    serde_json::to_string_pretty(&wrapper)?
493                } else {
494                    serde_json::to_string(&wrapper)?
495                }
496            } else {
497                if pretty_json {
498                    serde_json::to_string_pretty(&unique_paths)?
499                } else {
500                    serde_json::to_string(&unique_paths)?
501                }
502            };
503            println!("{}", json_output);
504        } else {
505            // Get or build QueryResponse for JSON output
506            let mut response = if let Some(resp) = query_response {
507                // We already have a response from search_with_metadata
508                // Apply truncation to the response (the flat_results were already truncated)
509                let mut resp = resp;
510
511                // Apply truncation to results
512                if !no_truncate {
513                    const MAX_PREVIEW_LENGTH: usize = 100;
514                    for file_group in resp.results.iter_mut() {
515                        for m in file_group.matches.iter_mut() {
516                            m.preview = truncate_preview(&m.preview, MAX_PREVIEW_LENGTH);
517                        }
518                    }
519                }
520
521                resp
522            } else {
523                // For AST queries, build a response with minimal metadata
524                // Group flat results by file path
525                use crate::models::{FileGroupedResult, IndexStatus, MatchResult, PaginationInfo};
526                use std::collections::HashMap;
527
528                let mut grouped: HashMap<String, Vec<crate::models::SearchResult>> = HashMap::new();
529                for result in &flat_results {
530                    grouped
531                        .entry(result.path.clone())
532                        .or_default()
533                        .push(result.clone());
534                }
535
536                // Load ContentReader for extracting context lines
537                use crate::content_store::ContentReader;
538                let local_cache = CacheManager::new(".");
539                let content_path = local_cache.path().join("content.bin");
540                let content_reader_opt = ContentReader::open(&content_path).ok();
541
542                let mut file_results: Vec<FileGroupedResult> = grouped
543                    .into_iter()
544                    .map(|(path, file_matches)| {
545                        // Get file_id for context extraction
546                        // Note: We use ContentReader's get_file_id_by_path() which returns array indices,
547                        // not database file_ids (which are AUTO INCREMENT values)
548                        let normalized_path = path.strip_prefix("./").unwrap_or(&path);
549                        let file_id_for_context = if let Some(reader) = &content_reader_opt {
550                            reader.get_file_id_by_path(normalized_path)
551                        } else {
552                            None
553                        };
554
555                        let language = file_matches.first().map(|r| r.lang).unwrap_or_default();
556                        let matches: Vec<MatchResult> = file_matches
557                            .into_iter()
558                            .map(|r| {
559                                // Extract context lines (default: 3 lines before and after)
560                                let (context_before, context_after) =
561                                    if let (Some(reader), Some(fid)) =
562                                        (&content_reader_opt, file_id_for_context)
563                                    {
564                                        reader
565                                            .get_context_by_line(fid, r.span.start_line, 3)
566                                            .unwrap_or_else(|_| (vec![], vec![]))
567                                    } else {
568                                        (vec![], vec![])
569                                    };
570
571                                MatchResult {
572                                    kind: r.kind,
573                                    symbol: r.symbol,
574                                    span: r.span,
575                                    preview: r.preview,
576                                    context_before,
577                                    context_after,
578                                }
579                            })
580                            .collect();
581                        FileGroupedResult {
582                            path,
583                            language,
584                            dependencies: None,
585                            matches,
586                        }
587                    })
588                    .collect();
589
590                // Sort by path for deterministic output
591                file_results.sort_by(|a, b| a.path.cmp(&b.path));
592
593                crate::models::QueryResponse {
594                    ai_instruction: None, // Will be populated below if ai_mode is true
595                    status: IndexStatus::Fresh,
596                    can_trust_results: true,
597                    warning: None,
598                    pagination: PaginationInfo {
599                        total: flat_results.len(),
600                        count: flat_results.len(),
601                        offset: offset.unwrap_or(0),
602                        limit,
603                        has_more: false, // AST already applied pagination
604                    },
605                    results: file_results,
606                }
607            };
608
609            // Generate AI instruction if in AI mode
610            if ai_mode {
611                let result_count: usize = response.results.iter().map(|fg| fg.matches.len()).sum();
612
613                response.ai_instruction = crate::query::generate_ai_instruction(
614                    result_count,
615                    response.pagination.total,
616                    response.pagination.has_more,
617                    symbols_mode,
618                    paths_only,
619                    use_ast,
620                    use_regex,
621                    language.is_some(),
622                    !glob_patterns.is_empty(),
623                    exact,
624                );
625            }
626
627            let json_output = if pretty_json {
628                serde_json::to_string_pretty(&response)?
629            } else {
630                serde_json::to_string(&response)?
631            };
632            println!("{}", json_output);
633
634            let result_count: usize = response.results.iter().map(|fg| fg.matches.len()).sum();
635            eprintln!(
636                "Found {} result{} in {}",
637                result_count,
638                if result_count == 1 { "" } else { "s" },
639                timing_str
640            );
641        }
642    } else {
643        // Standard output with formatting
644        if count_only {
645            let n = flat_results.len();
646            println!(
647                "Found {} result{} in {}",
648                n,
649                if n == 1 { "" } else { "s" },
650                timing_str
651            );
652            return Ok(());
653        }
654
655        if paths_only {
656            // Paths-only plain text mode: output one path per line
657            if flat_results.is_empty() {
658                eprintln!("No results found (searched in {}).", timing_str);
659            } else {
660                for result in &flat_results {
661                    println!("{}", result.path);
662                }
663                let n = flat_results.len();
664                eprintln!(
665                    "Found {} unique file{} in {}",
666                    n,
667                    if n == 1 { "" } else { "s" },
668                    timing_str
669                );
670            }
671        } else {
672            // Standard result formatting
673            if flat_results.is_empty() {
674                println!("No results found (searched in {}).", timing_str);
675            } else {
676                // Use formatter for pretty output
677                let formatter = crate::formatter::OutputFormatter::new(plain);
678                formatter.format_results(&flat_results, &pattern)?;
679
680                // Print summary at the bottom with pagination details
681                let n = flat_results.len();
682                if total_results > n {
683                    // Results were paginated - show detailed count
684                    println!(
685                        "\nFound {} result{} ({} total) in {}",
686                        n,
687                        if n == 1 { "" } else { "s" },
688                        total_results,
689                        timing_str
690                    );
691                    // Show pagination hint if there are more results available
692                    if has_more {
693                        println!("Use --limit and --offset to paginate");
694                    }
695                } else {
696                    // All results shown - simple count
697                    println!(
698                        "\nFound {} result{} in {}",
699                        n,
700                        if n == 1 { "" } else { "s" },
701                        timing_str
702                    );
703                }
704            }
705        }
706    }
707
708    Ok(())
709}
710
711/// Handle interactive mode (default when no command is given)
712pub(super) fn handle_interactive() -> Result<()> {
713    log::info!("Launching interactive mode");
714    crate::interactive::run_interactive()
715}
716
717#[cfg(test)]
718mod truncate_tests {
719    use super::truncate_preview;
720
721    /// Regression: the whitespace-search fallback returned a raw BYTE index, and
722    /// `preview[..byte]` panics mid-codepoint. It fires exactly on minified code —
723    /// no whitespace in the first `max_length` chars — which is also where non-ASCII
724    /// i18n tables live. This panicked `rfx query` and the MCP server.
725    #[test]
726    fn a_whitespace_free_multibyte_line_does_not_panic() {
727        for filler in ["日", "😀", "é", "\u{a0}"] {
728            let line = filler.repeat(500);
729            let out = truncate_preview(&line, 100);
730            assert!(out.ends_with('…'), "{filler}: {out}");
731            // Round-tripping proves the slice is well-formed UTF-8.
732            assert_eq!(String::from_utf8(out.clone().into_bytes()).unwrap(), out);
733        }
734    }
735
736    #[test]
737    fn short_previews_are_returned_verbatim() {
738        assert_eq!(truncate_preview("fn main() {}", 100), "fn main() {}");
739    }
740
741    #[test]
742    fn a_word_boundary_is_still_preferred_when_one_exists() {
743        let out = truncate_preview("alpha beta gamma delta epsilon zeta", 20);
744        assert!(out.ends_with('…'));
745        assert!(!out.contains("epsilon"), "should cut early: {out}");
746    }
747}