use anyhow::Result;
use std::time::Instant;
use indicatif::{ProgressBar, ProgressStyle};
use owo_colors::OwoColorize;
use crate::cache::CacheManager;
use crate::models::Language;
use crate::query::{QueryEngine, QueryFilter};
pub fn truncate_preview(preview: &str, max_length: usize) -> String {
if preview.len() <= max_length {
return preview.to_string();
}
let truncate_at = preview.char_indices()
.take(max_length)
.filter(|(_, c)| c.is_whitespace())
.last()
.map(|(i, _)| i)
.unwrap_or(max_length.min(preview.len()));
let mut truncated = preview[..truncate_at].to_string();
truncated.push('…');
truncated
}
pub(super) fn handle_query(
pattern: String,
symbols_flag: bool,
lang: Option<String>,
kind_str: Option<String>,
use_ast: bool,
use_regex: bool,
as_json: bool,
pretty_json: bool,
ai_mode: bool,
limit: Option<usize>,
offset: Option<usize>,
expand: bool,
file_pattern: Option<String>,
exact: bool,
use_contains: bool,
count_only: bool,
timeout_secs: u64,
plain: bool,
glob_patterns: Vec<String>,
exclude_patterns: Vec<String>,
paths_only: bool,
no_truncate: bool,
context_arg: Option<usize>,
all: bool,
force: bool,
include_dependencies: bool,
) -> Result<()> {
log::info!("Starting query command");
let as_json = as_json || ai_mode;
let cache = CacheManager::new(".");
let engine = QueryEngine::new(cache);
let language = if let Some(lang_str) = lang.as_deref() {
match Language::from_name(lang_str) {
Some(l) => Some(l),
None => anyhow::bail!(
"Unknown language: '{}'\n\nSupported languages:\n {}\n\nExample: rfx query \"pattern\" --lang rust",
lang_str, Language::supported_names_help()
),
}
} else {
None
};
let kind = if let Some(s) = kind_str.as_deref() {
let capitalized = {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars.flat_map(|c| c.to_lowercase())).collect(),
}
};
let parsed = capitalized.parse::<crate::models::SymbolKind>()
.unwrap_or(crate::models::SymbolKind::Unknown(s.to_string()));
if let crate::models::SymbolKind::Unknown(_) = &parsed {
anyhow::bail!(
"Unknown symbol kind: '{}'\n\nSupported kinds:\n function, class, struct, enum, interface, trait, \
constant, variable, method, module, namespace, type, macro, property, event, import, export, attribute\n\n\
Example: rfx query \"parse\" --kind function",
s
);
}
Some(parsed)
} else {
None
};
let symbols_mode = symbols_flag || kind.is_some();
if limit == Some(0) {
anyhow::bail!(
"--limit 0 is not valid. To return all results use --all (or -a).\n\
To return exactly 0 results is meaningless; omit --limit to use the default of 100."
);
}
let final_limit = if count_only {
None } else if all {
None } else if paths_only && limit.is_none() {
None } else if let Some(user_limit) = limit {
Some(user_limit) } else {
Some(100) };
if use_ast && language.is_none() {
anyhow::bail!(
"AST pattern matching requires a language to be specified.\n\
\n\
Use --lang to specify the language for tree-sitter parsing.\n\
\n\
Supported languages for AST queries:\n\
• rust, python, go, java, c, c++, c#, php, ruby, kotlin, zig, typescript, javascript\n\
\n\
Note: Vue and Svelte use line-based parsing and do not support AST queries.\n\
\n\
WARNING: AST queries are SLOW (500ms-2s+). Use --symbols instead for 95% of cases.\n\
\n\
Examples:\n\
• rfx query \"(function_definition) @fn\" --ast --lang python\n\
• rfx query \"(class_declaration) @class\" --ast --lang typescript --glob \"src/**/*.ts\""
);
}
if !as_json {
let mut has_errors = false;
if use_regex && use_contains {
eprintln!("{}", "ERROR: Cannot use --regex and --contains together.".red().bold());
eprintln!(" {} --regex for pattern matching (alternation, wildcards, etc.)", "•".dimmed());
eprintln!(" {} --contains for substring matching (expansive search)", "•".dimmed());
eprintln!("\n {} Choose one based on your needs:", "Tip:".cyan().bold());
eprintln!(" {} for OR logic: --regex", "pattern1|pattern2".yellow());
eprintln!(" {} for substring: --contains", "partial_text".yellow());
has_errors = true;
}
if exact && use_contains {
eprintln!("{}", "ERROR: Cannot use --exact and --contains together (contradictory).".red().bold());
eprintln!(" {} --exact requires exact symbol name match", "•".dimmed());
eprintln!(" {} --contains allows substring matching", "•".dimmed());
has_errors = true;
}
if file_pattern.is_some() && !glob_patterns.is_empty() {
eprintln!("{}", "WARNING: Both --file and --glob specified.".yellow().bold());
eprintln!(" {} --file does substring matching on file paths", "•".dimmed());
eprintln!(" {} --glob does pattern matching with wildcards", "•".dimmed());
eprintln!(" {} Both filters will apply (AND condition)", "Note:".dimmed());
eprintln!("\n {} Usually you only need one:", "Tip:".cyan().bold());
eprintln!(" {} for simple matching", "--file User.php".yellow());
eprintln!(" {} for pattern matching", "--glob src/**/*.php".yellow());
}
for pattern in &glob_patterns {
if (pattern.starts_with('\'') && pattern.ends_with('\'')) ||
(pattern.starts_with('"') && pattern.ends_with('"')) {
eprintln!("{}",
format!("WARNING: Glob pattern contains quotes: {}", pattern).yellow().bold()
);
eprintln!(" {} Shell quotes should not be part of the pattern", "Note:".dimmed());
eprintln!(" {} --glob src/**/*.rs", "Correct:".green());
eprintln!(" {} --glob 'src/**/*.rs'", "Wrong:".red().dimmed());
}
if pattern.contains("*/") && !pattern.contains("**/") {
eprintln!("{}",
format!("INFO: Glob '{}' uses * (matches one directory level)", pattern).cyan()
);
eprintln!(" {} Use ** for recursive matching across subdirectories", "Tip:".cyan().bold());
eprintln!(" {} → matches files in Models/ only", "app/Models/*.php".yellow());
eprintln!(" {} → matches files in Models/ and subdirs", "app/Models/**/*.php".green());
}
}
if has_errors {
anyhow::bail!("Invalid flag combination. Fix the errors above and try again.");
}
if let Some(ref fp) = file_pattern {
let looks_like_path = !fp.contains('*') && !fp.contains('?') &&
(fp.contains('/') || fp.contains('.'));
if looks_like_path && !std::path::Path::new(fp).exists() {
eprintln!("{}", format!("[warn] --file path not found on disk: {}", fp).yellow());
eprintln!(" Continuing with substring match — results will be empty if no indexed path contains '{}'.", fp);
}
}
}
let context_lines = context_arg.map(|n| n.min(10)).unwrap_or(0);
let filter = QueryFilter {
language,
kind,
use_ast,
use_regex,
limit: final_limit,
symbols_mode,
expand,
file_pattern,
exact,
use_contains,
timeout_secs,
glob_patterns: glob_patterns.clone(),
exclude_patterns,
paths_only,
offset,
force,
suppress_output: as_json, include_dependencies,
context_lines,
..Default::default()
};
let start = Instant::now();
let (query_response, mut flat_results, total_results, has_more) = if use_ast {
match engine.search_ast_all_files(&pattern, filter.clone()) {
Ok(ast_results) => {
let count = ast_results.len();
(None, ast_results, count, false)
}
Err(e) => {
if as_json {
let error_response = serde_json::json!({
"error": e.to_string(),
"query_too_broad": e.to_string().contains("Query too broad")
});
let json_output = if pretty_json {
serde_json::to_string_pretty(&error_response)?
} else {
serde_json::to_string(&error_response)?
};
println!("{}", json_output);
std::process::exit(1);
} else {
return Err(e);
}
}
}
} else {
match engine.search_with_metadata(&pattern, filter.clone()) {
Ok(response) => {
let total = response.pagination.total;
let has_more = response.pagination.has_more;
let flat = response.results.iter()
.flat_map(|file_group| {
file_group.matches.iter().map(move |m| {
crate::models::SearchResult {
path: file_group.path.clone(),
lang: crate::models::Language::Unknown, kind: m.kind.clone(),
symbol: m.symbol.clone(),
span: m.span.clone(),
preview: m.preview.clone(),
dependencies: file_group.dependencies.clone(),
}
})
})
.collect();
(Some(response), flat, total, has_more)
}
Err(e) => {
if as_json {
let error_response = serde_json::json!({
"error": e.to_string(),
"query_too_broad": e.to_string().contains("Query too broad")
});
let json_output = if pretty_json {
serde_json::to_string_pretty(&error_response)?
} else {
serde_json::to_string(&error_response)?
};
println!("{}", json_output);
std::process::exit(1);
} else {
return Err(e);
}
}
}
};
if !no_truncate {
const MAX_PREVIEW_LENGTH: usize = 100;
for result in &mut flat_results {
result.preview = truncate_preview(&result.preview, MAX_PREVIEW_LENGTH);
}
}
let elapsed = start.elapsed();
let timing_str = if elapsed.as_millis() < 1 {
format!("{:.1}ms", elapsed.as_secs_f64() * 1000.0)
} else {
format!("{}ms", elapsed.as_millis())
};
if as_json {
if count_only {
let count_response = serde_json::json!({
"count": total_results,
"timing_ms": elapsed.as_millis()
});
let json_output = if pretty_json {
serde_json::to_string_pretty(&count_response)?
} else {
serde_json::to_string(&count_response)?
};
println!("{}", json_output);
} else if paths_only {
let mut seen = std::collections::HashSet::new();
let unique_paths: Vec<String> = flat_results.iter()
.filter_map(|r| {
if seen.insert(r.path.clone()) { Some(r.path.clone()) } else { None }
})
.collect();
let json_output = if ai_mode {
let ai_instruction = crate::query::generate_ai_instruction(
unique_paths.len(),
total_results,
has_more,
symbols_mode,
true,
use_ast,
use_regex,
language.is_some(),
!glob_patterns.is_empty(),
exact,
);
let wrapper = serde_json::json!({
"ai_instruction": ai_instruction,
"count": unique_paths.len(),
"results": unique_paths,
});
if pretty_json { serde_json::to_string_pretty(&wrapper)? } else { serde_json::to_string(&wrapper)? }
} else {
if pretty_json {
serde_json::to_string_pretty(&unique_paths)?
} else {
serde_json::to_string(&unique_paths)?
}
};
println!("{}", json_output);
} else {
let mut response = if let Some(resp) = query_response {
let mut resp = resp;
if !no_truncate {
const MAX_PREVIEW_LENGTH: usize = 100;
for file_group in resp.results.iter_mut() {
for m in file_group.matches.iter_mut() {
m.preview = truncate_preview(&m.preview, MAX_PREVIEW_LENGTH);
}
}
}
resp
} else {
use crate::models::{PaginationInfo, IndexStatus, FileGroupedResult, MatchResult};
use std::collections::HashMap;
let mut grouped: HashMap<String, Vec<crate::models::SearchResult>> = HashMap::new();
for result in &flat_results {
grouped
.entry(result.path.clone())
.or_default()
.push(result.clone());
}
use crate::content_store::ContentReader;
let local_cache = CacheManager::new(".");
let content_path = local_cache.path().join("content.bin");
let content_reader_opt = ContentReader::open(&content_path).ok();
let mut file_results: Vec<FileGroupedResult> = grouped
.into_iter()
.map(|(path, file_matches)| {
let normalized_path = path.strip_prefix("./").unwrap_or(&path);
let file_id_for_context = if let Some(reader) = &content_reader_opt {
reader.get_file_id_by_path(normalized_path)
} else {
None
};
let language = file_matches.first().map(|r| r.lang).unwrap_or_default();
let matches: Vec<MatchResult> = file_matches
.into_iter()
.map(|r| {
let (context_before, context_after) = if let (Some(reader), Some(fid)) = (&content_reader_opt, file_id_for_context) {
reader.get_context_by_line(fid as u32, r.span.start_line, 3)
.unwrap_or_else(|_| (vec![], vec![]))
} else {
(vec![], vec![])
};
MatchResult {
kind: r.kind,
symbol: r.symbol,
span: r.span,
preview: r.preview,
context_before,
context_after,
}
})
.collect();
FileGroupedResult {
path,
language,
dependencies: None,
matches,
}
})
.collect();
file_results.sort_by(|a, b| a.path.cmp(&b.path));
crate::models::QueryResponse {
ai_instruction: None, status: IndexStatus::Fresh,
can_trust_results: true,
warning: None,
pagination: PaginationInfo {
total: flat_results.len(),
count: flat_results.len(),
offset: offset.unwrap_or(0),
limit,
has_more: false, },
results: file_results,
}
};
if ai_mode {
let result_count: usize = response.results.iter().map(|fg| fg.matches.len()).sum();
response.ai_instruction = crate::query::generate_ai_instruction(
result_count,
response.pagination.total,
response.pagination.has_more,
symbols_mode,
paths_only,
use_ast,
use_regex,
language.is_some(),
!glob_patterns.is_empty(),
exact,
);
}
let json_output = if pretty_json {
serde_json::to_string_pretty(&response)?
} else {
serde_json::to_string(&response)?
};
println!("{}", json_output);
let result_count: usize = response.results.iter().map(|fg| fg.matches.len()).sum();
eprintln!("Found {} result{} in {}", result_count, if result_count == 1 { "" } else { "s" }, timing_str);
}
} else {
if count_only {
let n = flat_results.len();
println!("Found {} result{} in {}", n, if n == 1 { "" } else { "s" }, timing_str);
return Ok(());
}
if paths_only {
if flat_results.is_empty() {
eprintln!("No results found (searched in {}).", timing_str);
} else {
for result in &flat_results {
println!("{}", result.path);
}
let n = flat_results.len();
eprintln!("Found {} unique file{} in {}", n, if n == 1 { "" } else { "s" }, timing_str);
}
} else {
if flat_results.is_empty() {
println!("No results found (searched in {}).", timing_str);
} else {
let formatter = crate::formatter::OutputFormatter::new(plain);
formatter.format_results(&flat_results, &pattern)?;
let n = flat_results.len();
if total_results > n {
println!("\nFound {} result{} ({} total) in {}", n, if n == 1 { "" } else { "s" }, total_results, timing_str);
if has_more {
println!("Use --limit and --offset to paginate");
}
} else {
println!("\nFound {} result{} in {}", n, if n == 1 { "" } else { "s" }, timing_str);
}
}
}
}
Ok(())
}
pub(super) fn handle_interactive() -> Result<()> {
log::info!("Launching interactive mode");
crate::interactive::run_interactive()
}