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 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 let as_json = as_json || ai_mode;
73
74 let cache = CacheManager::new(".");
75 let engine = QueryEngine::new(cache);
76
77 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 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 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 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 let symbols_mode = symbols_flag || kind.is_some();
140
141 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 let final_limit = if count_only || all || (paths_only && limit.is_none()) {
156 None } else if let Some(user_limit) = limit {
158 Some(user_limit) } else {
160 Some(100) };
162
163 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 if !as_json {
186 let mut has_errors = false;
187
188 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 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 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 for pattern in &glob_patterns {
257 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 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 if let Some(ref fp) = file_pattern {
306 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 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, include_dependencies,
344 context_lines,
345 ..Default::default()
346 };
347
348 let start = Instant::now();
350
351 let (query_response, mut flat_results, total_results, has_more) = if use_ast {
354 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 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 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 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, 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 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 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 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 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 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 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 let mut response = if let Some(resp) = query_response {
507 let mut resp = resp;
510
511 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 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 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 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 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 file_results.sort_by(|a, b| a.path.cmp(&b.path));
592
593 crate::models::QueryResponse {
594 ai_instruction: None, 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, },
605 results: file_results,
606 }
607 };
608
609 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 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 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 if flat_results.is_empty() {
674 println!("No results found (searched in {}).", timing_str);
675 } else {
676 let formatter = crate::formatter::OutputFormatter::new(plain);
678 formatter.format_results(&flat_results, &pattern)?;
679
680 let n = flat_results.len();
682 if total_results > n {
683 println!(
685 "\nFound {} result{} ({} total) in {}",
686 n,
687 if n == 1 { "" } else { "s" },
688 total_results,
689 timing_str
690 );
691 if has_more {
693 println!("Use --limit and --offset to paginate");
694 }
695 } else {
696 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
711pub(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 #[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 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}