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
8pub fn truncate_preview(preview: &str, max_length: usize) -> String {
11 if preview.len() <= max_length {
12 return preview.to_string();
13 }
14
15 let truncate_at = preview
17 .char_indices()
18 .take(max_length)
19 .filter(|(_, c)| c.is_whitespace())
20 .last()
21 .map(|(i, _)| i)
22 .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#[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 timing: bool,
51 ai_mode: bool,
52 limit: Option<usize>,
53 offset: Option<usize>,
54 expand: bool,
55 file_pattern: Option<String>,
56 exact: bool,
57 use_contains: bool,
58 ignore_case: bool,
59 include_locks: bool,
60 include_generated: bool,
61 count_only: bool,
62 timeout_secs: u64,
63 plain: bool,
64 glob_patterns: Vec<String>,
65 exclude_patterns: Vec<String>,
66 paths_only: bool,
67 no_truncate: bool,
68 context_arg: Option<usize>,
69 all: bool,
70 force: bool,
71 include_dependencies: bool,
72) -> Result<()> {
73 log::info!("Starting query command");
74
75 let as_json = as_json || ai_mode;
77
78 let cache = CacheManager::new(".");
79 let engine = QueryEngine::new(cache);
80
81 let language = if let Some(lang_str) = lang.as_deref() {
83 match Language::from_name(lang_str) {
84 Some(l) => Some(l),
85 None => anyhow::bail!(
86 "Unknown language: '{}'\n\nSupported languages:\n {}\n\nExample: rfx query \"pattern\" --lang rust",
87 lang_str,
88 Language::supported_names_help()
89 ),
90 }
91 } else {
92 None
93 };
94
95 if language == Some(Language::Swift) {
97 eprintln!(
98 "{}: Swift symbol extraction is temporarily disabled (tree-sitter-swift 0.7.x grammar incompatibility). \
99Full-text search will still work, but --symbols queries will return no results.",
100 "Warning".yellow().bold()
101 );
102 }
103
104 if include_dependencies && matches!(language, Some(l) if l != Language::Rust) {
106 eprintln!(
107 "{}: --dependencies is currently only supported for Rust files. \
108No dependency data will be included for {} files.",
109 "Warning".yellow().bold(),
110 lang.as_deref().unwrap_or("non-Rust")
111 );
112 }
113
114 let kind = if let Some(s) = kind_str.as_deref() {
116 let capitalized = {
117 let mut chars = s.chars();
118 match chars.next() {
119 None => String::new(),
120 Some(first) => first
121 .to_uppercase()
122 .chain(chars.flat_map(|c| c.to_lowercase()))
123 .collect(),
124 }
125 };
126 let parsed = capitalized
127 .parse::<crate::models::SymbolKind>()
128 .unwrap_or(crate::models::SymbolKind::Unknown(s.to_string()));
129 if let crate::models::SymbolKind::Unknown(_) = &parsed {
130 anyhow::bail!(
131 "Unknown symbol kind: '{}'\n\nSupported kinds:\n function, class, struct, enum, interface, trait, \
132 constant, variable, method, module, namespace, type, macro, property, event, import, export, attribute\n\n\
133 Example: rfx query \"parse\" --kind function",
134 s
135 );
136 }
137 Some(parsed)
138 } else {
139 None
140 };
141
142 let symbols_mode = symbols_flag || kind.is_some();
144
145 if limit == Some(0) {
147 anyhow::bail!(
148 "--limit 0 is not valid. To return all results use --all (or -a).\n\
149 To return exactly 0 results is meaningless; omit --limit to use the default of 100."
150 );
151 }
152
153 let final_limit = if count_only || all || (paths_only && limit.is_none()) {
160 None } else if let Some(user_limit) = limit {
162 Some(user_limit) } else {
164 Some(100) };
166
167 if use_ast && language.is_none() {
169 anyhow::bail!(
170 "AST pattern matching requires a language to be specified.\n\
171 \n\
172 Use --lang to specify the language for tree-sitter parsing.\n\
173 \n\
174 Supported languages for AST queries:\n\
175 • rust, python, go, java, c, c++, c#, php, ruby, kotlin, zig, typescript, javascript\n\
176 \n\
177 Note: Vue and Svelte use line-based parsing and do not support AST queries.\n\
178 \n\
179 WARNING: AST queries are SLOW (500ms-2s+). Use --symbols instead for 95% of cases.\n\
180 \n\
181 Examples:\n\
182 • rfx query \"(function_definition) @fn\" --ast --lang python\n\
183 • rfx query \"(class_declaration) @class\" --ast --lang typescript --glob \"src/**/*.ts\""
184 );
185 }
186
187 if !as_json {
190 let mut has_errors = false;
191
192 if use_regex && use_contains {
194 eprintln!(
195 "{}",
196 "ERROR: Cannot use --regex and --contains together."
197 .red()
198 .bold()
199 );
200 eprintln!(
201 " {} --regex for pattern matching (alternation, wildcards, etc.)",
202 "•".dimmed()
203 );
204 eprintln!(
205 " {} --contains for substring matching (expansive search)",
206 "•".dimmed()
207 );
208 eprintln!(
209 "\n {} Choose one based on your needs:",
210 "Tip:".cyan().bold()
211 );
212 eprintln!(" {} for OR logic: --regex", "pattern1|pattern2".yellow());
213 eprintln!(" {} for substring: --contains", "partial_text".yellow());
214 has_errors = true;
215 }
216
217 if exact && use_contains {
219 eprintln!(
220 "{}",
221 "ERROR: Cannot use --exact and --contains together (contradictory)."
222 .red()
223 .bold()
224 );
225 eprintln!(
226 " {} --exact requires exact symbol name match",
227 "•".dimmed()
228 );
229 eprintln!(" {} --contains allows substring matching", "•".dimmed());
230 has_errors = true;
231 }
232
233 if file_pattern.is_some() && !glob_patterns.is_empty() {
235 eprintln!(
236 "{}",
237 "WARNING: Both --file and --glob specified.".yellow().bold()
238 );
239 eprintln!(
240 " {} --file does substring matching on file paths",
241 "•".dimmed()
242 );
243 eprintln!(
244 " {} --glob does pattern matching with wildcards",
245 "•".dimmed()
246 );
247 eprintln!(
248 " {} Both filters will apply (AND condition)",
249 "Note:".dimmed()
250 );
251 eprintln!("\n {} Usually you only need one:", "Tip:".cyan().bold());
252 eprintln!(" {} for simple matching", "--file User.php".yellow());
253 eprintln!(
254 " {} for pattern matching",
255 "--glob src/**/*.php".yellow()
256 );
257 }
258
259 for pattern in &glob_patterns {
261 if (pattern.starts_with('\'') && pattern.ends_with('\''))
263 || (pattern.starts_with('"') && pattern.ends_with('"'))
264 {
265 eprintln!(
266 "{}",
267 format!("WARNING: Glob pattern contains quotes: {}", pattern)
268 .yellow()
269 .bold()
270 );
271 eprintln!(
272 " {} Shell quotes should not be part of the pattern",
273 "Note:".dimmed()
274 );
275 eprintln!(" {} --glob src/**/*.rs", "Correct:".green());
276 eprintln!(" {} --glob 'src/**/*.rs'", "Wrong:".red().dimmed());
277 }
278
279 if pattern.contains("*/") && !pattern.contains("**/") {
281 eprintln!(
282 "{}",
283 format!(
284 "INFO: Glob '{}' uses * (matches one directory level)",
285 pattern
286 )
287 .cyan()
288 );
289 eprintln!(
290 " {} Use ** for recursive matching across subdirectories",
291 "Tip:".cyan().bold()
292 );
293 eprintln!(
294 " {} → matches files in Models/ only",
295 "app/Models/*.php".yellow()
296 );
297 eprintln!(
298 " {} → matches files in Models/ and subdirs",
299 "app/Models/**/*.php".green()
300 );
301 }
302 }
303
304 if has_errors {
305 anyhow::bail!("Invalid flag combination. Fix the errors above and try again.");
306 }
307
308 if let Some(ref fp) = file_pattern {
310 let looks_like_path =
312 !fp.contains('*') && !fp.contains('?') && (fp.contains('/') || fp.contains('.'));
313 if looks_like_path && !std::path::Path::new(fp).exists() {
314 eprintln!(
315 "{}",
316 format!("[warn] --file path not found on disk: {}", fp).yellow()
317 );
318 eprintln!(
319 " Continuing with substring match — results will be empty if no indexed path contains '{}'.",
320 fp
321 );
322 }
323 }
324 }
325
326 let context_lines = context_arg.map(|n| n.min(10)).unwrap_or(0);
328
329 let filter = QueryFilter {
330 language,
331 kind,
332 use_ast,
333 use_regex,
334 limit: final_limit,
335 symbols_mode,
336 expand,
337 file_pattern,
338 exact,
339 use_contains,
340 ignore_case,
341 include_locks,
342 include_generated,
343 count_only,
344 timeout_secs,
345 glob_patterns: glob_patterns.clone(),
346 exclude_patterns,
347 paths_only,
348 offset,
349 force,
350 suppress_output: as_json, include_dependencies,
352 context_lines,
353 collect_timings: timing,
354 ..Default::default()
355 };
356
357 let start = Instant::now();
359
360 let (query_response, mut flat_results, total_results, has_more) = if use_ast {
363 match engine.search_ast_all_files(&pattern, filter.clone()) {
365 Ok(ast_results) => {
366 let count = ast_results.len();
367 (None, ast_results, Some(count), false)
368 }
369 Err(e) => {
370 if as_json {
371 let error_response = serde_json::json!({
373 "error": e.to_string(),
374 "query_too_broad": e.to_string().contains("Query too broad")
375 });
376 let json_output = if pretty_json {
377 serde_json::to_string_pretty(&error_response)?
378 } else {
379 serde_json::to_string(&error_response)?
380 };
381 println!("{}", json_output);
382 std::process::exit(1);
383 } else {
384 return Err(e);
385 }
386 }
387 }
388 } else {
389 match engine.search_with_metadata(&pattern, filter.clone()) {
391 Ok(response) => {
392 let total = response.pagination.exact_total();
395 let has_more = response.pagination.has_more;
396
397 let flat = response
399 .results
400 .iter()
401 .flat_map(|file_group| {
402 file_group.matches.iter().map(move |m| {
403 crate::models::SearchResult {
404 path: file_group.path.clone(),
405 lang: crate::models::Language::Unknown, kind: m.kind.clone(),
407 symbol: m.symbol.clone(),
408 span: m.span.clone(),
409 preview: m.preview.clone(),
410 dependencies: file_group.dependencies.clone(),
411 }
412 })
413 })
414 .collect();
415
416 (Some(response), flat, total, has_more)
417 }
418 Err(e) => {
419 if as_json {
420 let error_response = serde_json::json!({
422 "error": e.to_string(),
423 "query_too_broad": e.to_string().contains("Query too broad")
424 });
425 let json_output = if pretty_json {
426 serde_json::to_string_pretty(&error_response)?
427 } else {
428 serde_json::to_string(&error_response)?
429 };
430 println!("{}", json_output);
431 std::process::exit(1);
432 } else {
433 return Err(e);
434 }
435 }
436 }
437 };
438
439 let engine_warnings: Vec<String> = query_response
443 .as_ref()
444 .map(|r| r.warnings.clone())
445 .unwrap_or_default();
446 let engine_hint: Option<String> = query_response.as_ref().and_then(|r| r.hint.clone());
447 let best_total: usize = query_response
450 .as_ref()
451 .map(|r| r.pagination.best_total())
452 .or(total_results)
453 .unwrap_or(0);
454 let notes_to_stderr = || {
455 for w in &engine_warnings {
456 eprintln!("{}: {}", "Warning".yellow().bold(), w);
457 }
458 if let Some(h) = &engine_hint {
459 eprintln!("{}: {}", "Hint".cyan().bold(), h);
460 }
461 };
462
463 if !no_truncate {
465 const MAX_PREVIEW_LENGTH: usize = 100;
466 for result in &mut flat_results {
467 result.preview = truncate_preview(&result.preview, MAX_PREVIEW_LENGTH);
468 }
469 }
470
471 let elapsed = start.elapsed();
472
473 if timing {
474 let ms = |us: u64| us as f64 / 1000.0;
475 match query_response.as_ref().and_then(|r| r.timings.as_ref()) {
476 Some(t) => eprintln!(
477 "timing: open {:.2}ms | candidates {:.2}ms | verify {:.2}ms | status wait {:.2}ms (compute {:.2}ms) | group {:.2}ms | engine {:.2}ms | wall {:.2}ms",
478 ms(t.open_us),
479 ms(t.candidates_us),
480 ms(t.verify_us),
481 ms(t.status_us),
482 ms(t.status_compute_us),
483 ms(t.group_us),
484 ms(t.total_us),
485 elapsed.as_secs_f64() * 1000.0
486 ),
487 None => eprintln!("timing: wall {:.2}ms", elapsed.as_secs_f64() * 1000.0),
488 }
489 }
490
491 let timing_str = if elapsed.as_millis() < 1 {
493 format!("{:.1}ms", elapsed.as_secs_f64() * 1000.0)
494 } else {
495 format!("{}ms", elapsed.as_millis())
496 };
497
498 if as_json {
499 if count_only {
500 let mut count_response = serde_json::json!({
504 "count": total_results.unwrap_or(flat_results.len()),
505 "timing_ms": elapsed.as_millis()
506 });
507 if !engine_warnings.is_empty() {
508 count_response["warnings"] = serde_json::json!(engine_warnings);
509 }
510 if let Some(h) = &engine_hint {
511 count_response["hint"] = serde_json::json!(h);
512 }
513 if let Some(reason) = query_response.as_ref().and_then(|r| r.excluded_reason) {
514 count_response["excluded_reason"] = serde_json::json!(reason);
515 }
516 let json_output = if pretty_json {
517 serde_json::to_string_pretty(&count_response)?
518 } else {
519 serde_json::to_string(&count_response)?
520 };
521 println!("{}", json_output);
522 } else if paths_only {
523 let mut seen = std::collections::HashSet::new();
525 let unique_paths: Vec<String> = flat_results
526 .iter()
527 .filter_map(|r| {
528 if seen.insert(r.path.clone()) {
529 Some(r.path.clone())
530 } else {
531 None
532 }
533 })
534 .collect();
535
536 let json_output = if ai_mode {
537 let ai_instruction = crate::query::generate_ai_instruction(
539 unique_paths.len(),
540 best_total,
541 has_more,
542 symbols_mode,
543 true,
544 use_ast,
545 use_regex,
546 language.is_some(),
547 !glob_patterns.is_empty(),
548 exact,
549 );
550 let wrapper = serde_json::json!({
551 "ai_instruction": ai_instruction,
552 "count": unique_paths.len(),
553 "results": unique_paths,
554 });
555 if pretty_json {
556 serde_json::to_string_pretty(&wrapper)?
557 } else {
558 serde_json::to_string(&wrapper)?
559 }
560 } else {
561 if pretty_json {
562 serde_json::to_string_pretty(&unique_paths)?
563 } else {
564 serde_json::to_string(&unique_paths)?
565 }
566 };
567 println!("{}", json_output);
568 notes_to_stderr();
570 } else {
571 let mut response = if let Some(resp) = query_response {
573 let mut resp = resp;
576
577 if !no_truncate {
579 const MAX_PREVIEW_LENGTH: usize = 100;
580 for file_group in resp.results.iter_mut() {
581 for m in file_group.matches.iter_mut() {
582 m.preview = truncate_preview(&m.preview, MAX_PREVIEW_LENGTH);
583 }
584 }
585 }
586
587 resp
588 } else {
589 use crate::models::{FileGroupedResult, IndexStatus, MatchResult, PaginationInfo};
592 use std::collections::HashMap;
593
594 let mut grouped: HashMap<String, Vec<crate::models::SearchResult>> = HashMap::new();
595 for result in &flat_results {
596 grouped
597 .entry(result.path.clone())
598 .or_default()
599 .push(result.clone());
600 }
601
602 use crate::content_store::ContentReader;
604 let local_cache = CacheManager::new(".");
605 let content_path = local_cache.path().join("content.bin");
606 let content_reader_opt = ContentReader::open(&content_path).ok();
607
608 let mut file_results: Vec<FileGroupedResult> = grouped
609 .into_iter()
610 .map(|(path, file_matches)| {
611 let normalized_path = path.strip_prefix("./").unwrap_or(&path);
615 let file_id_for_context = if let Some(reader) = &content_reader_opt {
616 reader.get_file_id_by_path(normalized_path)
617 } else {
618 None
619 };
620
621 let language = file_matches.first().map(|r| r.lang).unwrap_or_default();
622 let matches: Vec<MatchResult> = file_matches
623 .into_iter()
624 .map(|r| {
625 let (context_before, context_after) =
627 if let (Some(reader), Some(fid)) =
628 (&content_reader_opt, file_id_for_context)
629 {
630 reader
631 .get_context_by_line(fid, r.span.start_line, 3)
632 .unwrap_or_else(|_| (vec![], vec![]))
633 } else {
634 (vec![], vec![])
635 };
636
637 MatchResult {
638 kind: r.kind,
639 symbol: r.symbol,
640 span: r.span,
641 preview: r.preview,
642 context_before,
643 context_after,
644 }
645 })
646 .collect();
647 FileGroupedResult {
648 path,
649 language,
650 dependencies: None,
651 matches,
652 }
653 })
654 .collect();
655
656 file_results.sort_by(|a, b| a.path.cmp(&b.path));
658
659 crate::models::QueryResponse {
660 ai_instruction: None, status: IndexStatus::Fresh,
662 can_trust_results: true,
663 warning: None,
664 pagination: PaginationInfo {
665 total: Some(flat_results.len()),
666 count: flat_results.len(),
667 offset: offset.unwrap_or(0),
668 limit,
669 has_more: false, total_is_exact: true,
671 approx_total: None,
672 },
673 results: file_results,
674 substring_hint_count: None,
675 excluded_reason: None,
676 excluded_by_default: None,
677 file_count: None,
678 warnings: Vec::new(),
679 hint: None,
680 timings: None,
681 }
682 };
683
684 if ai_mode {
686 let result_count: usize = response.results.iter().map(|fg| fg.matches.len()).sum();
687
688 response.ai_instruction = crate::query::generate_ai_instruction(
689 result_count,
690 response.pagination.best_total(),
691 response.pagination.has_more,
692 symbols_mode,
693 paths_only,
694 use_ast,
695 use_regex,
696 language.is_some(),
697 !glob_patterns.is_empty(),
698 exact,
699 );
700 }
701
702 let json_output = if pretty_json {
703 serde_json::to_string_pretty(&response)?
704 } else {
705 serde_json::to_string(&response)?
706 };
707 println!("{}", json_output);
708
709 let result_count: usize = response.results.iter().map(|fg| fg.matches.len()).sum();
710 eprintln!(
711 "Found {} result{} in {}",
712 result_count,
713 if result_count == 1 { "" } else { "s" },
714 timing_str
715 );
716 }
717 } else {
718 notes_to_stderr();
720 if count_only {
721 let n = total_results.unwrap_or(flat_results.len());
724 println!(
725 "Found {} result{} in {}",
726 n,
727 if n == 1 { "" } else { "s" },
728 timing_str
729 );
730 return Ok(());
731 }
732
733 if paths_only {
734 if flat_results.is_empty() {
736 eprintln!("No results found (searched in {}).", timing_str);
737 } else {
738 for result in &flat_results {
739 println!("{}", result.path);
740 }
741 let n = flat_results.len();
742 eprintln!(
743 "Found {} unique file{} in {}",
744 n,
745 if n == 1 { "" } else { "s" },
746 timing_str
747 );
748 }
749 } else {
750 if flat_results.is_empty() {
752 println!("No results found (searched in {}).", timing_str);
753 } else {
754 let formatter = crate::formatter::OutputFormatter::new(plain);
756 formatter.format_results(&flat_results, &pattern)?;
757
758 let n = flat_results.len();
760 let plural = if n == 1 { "" } else { "s" };
761 let approx = query_response
762 .as_ref()
763 .and_then(|r| r.pagination.approx_total);
764 match total_results {
765 None => {
768 match approx {
769 Some(est) => println!(
770 "\nFound {} result{} (~{} total, estimated) in {}",
771 n, plural, est, timing_str
772 ),
773 None => println!(
774 "\nFound {} result{} (more available, total unknown) in {}",
775 n, plural, timing_str
776 ),
777 }
778 println!(
779 "Use --limit/--offset to paginate, or --count for the exact total"
780 );
781 }
782 Some(total) if total > n => {
783 println!(
784 "\nFound {} result{} ({} total) in {}",
785 n, plural, total, timing_str
786 );
787 if has_more {
788 println!("Use --limit and --offset to paginate");
789 }
790 }
791 Some(_) => {
792 println!("\nFound {} result{} in {}", n, plural, timing_str);
794 }
795 }
796 }
797 }
798 }
799
800 Ok(())
801}
802
803pub(super) fn handle_interactive() -> Result<()> {
805 log::info!("Launching interactive mode");
806 crate::interactive::run_interactive()
807}
808
809#[cfg(test)]
810mod truncate_tests {
811 use super::truncate_preview;
812
813 #[test]
818 fn a_whitespace_free_multibyte_line_does_not_panic() {
819 for filler in ["日", "😀", "é", "\u{a0}"] {
820 let line = filler.repeat(500);
821 let out = truncate_preview(&line, 100);
822 assert!(out.ends_with('…'), "{filler}: {out}");
823 assert_eq!(String::from_utf8(out.clone().into_bytes()).unwrap(), out);
825 }
826 }
827
828 #[test]
829 fn short_previews_are_returned_verbatim() {
830 assert_eq!(truncate_preview("fn main() {}", 100), "fn main() {}");
831 }
832
833 #[test]
834 fn a_word_boundary_is_still_preferred_when_one_exists() {
835 let out = truncate_preview("alpha beta gamma delta epsilon zeta", 20);
836 assert!(out.ends_with('…'));
837 assert!(!out.contains("epsilon"), "should cut early: {out}");
838 }
839}