use std::path::{Path, PathBuf};
use super::super::super::command::{FindOptions, OccurrencesOptions};
use super::super::{DispatchResult, GlobalOptions, load_or_create_query_snapshot_for_roots};
use crate::analyzer::occurrences::{
FileScope, OccurrenceResults, ReportOptions, ScanOptions, enrich_with_snapshot,
scan_files_with, scan_files_with_regex, scan_files_with_scope,
};
use crate::analyzer::search::{FuzzySuggestion, literal_fuzzy_suggestions};
use crate::snapshot::Snapshot;
pub fn handle_occurrences_command(
opts: &OccurrencesOptions,
global: &GlobalOptions,
) -> DispatchResult {
let roots: Vec<PathBuf> = if opts.roots.is_empty() {
vec![PathBuf::from(".")]
} else {
opts.roots.clone()
};
let query_global = query_global_options(global);
let snapshot = match load_or_create_query_snapshot_for_roots(&roots, &query_global) {
Ok(s) => s,
Err(e) => {
eprintln!("[loct][error] {}", e);
return DispatchResult::Exit(1);
}
};
let base = roots.first().cloned().unwrap_or_else(|| PathBuf::from("."));
let contents = read_snapshot_contents(&snapshot, &base);
let borrowed = contents
.iter()
.map(|(p, c)| (p.as_str(), c.as_str()))
.collect::<Vec<_>>();
let mut results = scan_files_with(
borrowed,
opts.ident.trim(),
ScanOptions {
whole_token: opts.whole_token,
},
);
enrich_with_snapshot(&mut results, &snapshot);
results.apply_report(ReportOptions {
group_by_file: opts.group_by_file,
count_only: opts.count_only,
offset: opts.offset,
limit: opts.limit,
});
if global.json {
match serde_json::to_string_pretty(&results) {
Ok(json) => println!("{}", json),
Err(e) => {
eprintln!("[loct][error] Failed to serialize results: {}", e);
return DispatchResult::Exit(1);
}
}
} else {
print_human(&results);
}
DispatchResult::Exit(0)
}
pub fn handle_find_literal_command(opts: &FindOptions, global: &GlobalOptions) -> DispatchResult {
let ident = literal_find_ident(opts);
let ident = match ident {
Some(id) if !id.trim().is_empty() => id,
_ => {
eprintln!(
"[loct][error] 'find --literal' requires a query. Usage: loct find --literal <query>"
);
return DispatchResult::Exit(1);
}
};
let roots = vec![PathBuf::from(".")];
let query_global = query_global_options(global);
let snapshot = match load_or_create_query_snapshot_for_roots(&roots, &query_global) {
Ok(s) => s,
Err(e) => {
eprintln!("[loct][error] {}", e);
return DispatchResult::Exit(1);
}
};
let base = roots.first().cloned().unwrap_or_else(|| PathBuf::from("."));
let contents = read_snapshot_contents(&snapshot, &base);
let borrowed = contents
.iter()
.map(|(p, c)| (p.as_str(), c.as_str()))
.collect::<Vec<_>>();
let mut literal_matches = scan_files_with_scope(
borrowed,
ident.trim(),
ScanOptions {
whole_token: opts.whole_token,
},
FileScope {
file: opts.file.as_deref(),
},
);
enrich_with_snapshot(&mut literal_matches, &snapshot);
literal_matches.apply_report(ReportOptions {
group_by_file: opts.group_by_file,
count_only: opts.count_only,
offset: opts.offset,
limit: opts.limit,
});
let fuzzy_suggestions = literal_fuzzy_suggestions(ident.trim(), &snapshot.files);
let looks_like_regex = query_has_regex_metachars(ident.trim());
let absence_trustworthy = literal_matches.total > 0 || !looks_like_regex;
if global.json {
let payload = serde_json::json!({
"mode": "literal",
"query": ident,
"literal_matches": literal_matches,
"fuzzy_suggestions": fuzzy_suggestions,
"literal_trust": {
"query_has_regex_metachars": looks_like_regex,
"matched_as_exact_string": true,
"absence_trustworthy": absence_trustworthy,
},
});
match serde_json::to_string_pretty(&payload) {
Ok(json) => println!("{}", json),
Err(e) => {
eprintln!("[loct][error] Failed to serialize results: {}", e);
return DispatchResult::Exit(1);
}
}
} else {
print_literal_find_human(ident.trim(), &literal_matches, &fuzzy_suggestions);
}
DispatchResult::Exit(0)
}
pub fn handle_find_regex_command(opts: &FindOptions, global: &GlobalOptions) -> DispatchResult {
let pattern = literal_find_ident(opts);
let pattern = match pattern {
Some(p) if !p.trim().is_empty() => p,
_ => {
eprintln!(
"[loct][error] 'find --regex' requires a pattern. Usage: loct find --regex <pattern>"
);
return DispatchResult::Exit(1);
}
};
let re = match regex::Regex::new(pattern.trim()) {
Ok(re) => re,
Err(e) => {
eprintln!(
"[loct][error] invalid --regex pattern '{}': {}",
pattern.trim(),
e
);
return DispatchResult::Exit(1);
}
};
let roots = vec![PathBuf::from(".")];
let query_global = query_global_options(global);
let snapshot = match load_or_create_query_snapshot_for_roots(&roots, &query_global) {
Ok(s) => s,
Err(e) => {
eprintln!("[loct][error] {}", e);
return DispatchResult::Exit(1);
}
};
let base = roots.first().cloned().unwrap_or_else(|| PathBuf::from("."));
let contents = read_snapshot_contents(&snapshot, &base);
let borrowed = contents
.iter()
.map(|(p, c)| (p.as_str(), c.as_str()))
.collect::<Vec<_>>();
let mut matches = scan_files_with_regex(
borrowed,
&re,
FileScope {
file: opts.file.as_deref(),
},
);
matches.apply_report(ReportOptions {
group_by_file: opts.group_by_file,
count_only: opts.count_only,
offset: opts.offset,
limit: opts.limit,
});
if global.json {
let payload = serde_json::json!({
"mode": "regex",
"query": pattern,
"regex_matches": matches,
"regex_trust": {
"pattern_compiled": true,
"absence_trustworthy": true,
},
});
match serde_json::to_string_pretty(&payload) {
Ok(json) => println!("{}", json),
Err(e) => {
eprintln!("[loct][error] Failed to serialize results: {}", e);
return DispatchResult::Exit(1);
}
}
} else {
print_regex_find_human(pattern.trim(), &matches);
}
DispatchResult::Exit(0)
}
fn print_regex_find_human(pattern: &str, results: &OccurrenceResults) {
println!(
"Regex matches of /{}/ ({} in {} file(s)) [source: regex]",
pattern, results.total, results.files_matched
);
if !results.coverage_line.is_empty() {
println!(" {}", results.coverage_line);
}
if results.total == 0 {
println!(" (not found — pattern evaluated; absence is trustworthy)");
return;
}
print_file_rollup(results);
print_page(results);
print_role_summary(results);
if results.slim {
println!(" (match list suppressed — count_only/slim)");
return;
}
for occ in &results.occurrences {
println!(
" {}:{}:{} [{}] {}",
occ.file,
occ.line,
occ.column,
occ.match_role.as_str(),
occ.context
);
}
}
fn query_has_regex_metachars(query: &str) -> bool {
query.chars().any(|c| {
matches!(
c,
'\\' | '[' | ']' | '(' | ')' | '{' | '}' | '+' | '*' | '?' | '^' | '$' | '|'
)
})
}
fn query_global_options(global: &GlobalOptions) -> GlobalOptions {
let mut scoped = global.clone();
if !scoped.verbose {
scoped.quiet = true;
}
scoped
}
fn literal_find_ident(opts: &FindOptions) -> Option<String> {
opts.query
.clone()
.or_else(|| opts.queries.first().cloned())
.or_else(|| opts.symbol.clone())
.or_else(|| opts.similar.clone())
}
fn read_snapshot_contents(snapshot: &Snapshot, base: &Path) -> Vec<(String, String)> {
let mut contents: Vec<(String, String)> = Vec::new();
for file in &snapshot.files {
let resolved = resolve_path(base, &file.path);
if let Ok(text) = std::fs::read_to_string(&resolved) {
contents.push((file.path.clone(), text));
}
}
contents
}
fn resolve_path(base: &Path, rel: &str) -> PathBuf {
let joined = base.join(rel);
if joined.exists() {
return joined;
}
let raw = PathBuf::from(rel);
if raw.exists() {
return raw;
}
joined
}
fn print_human(results: &OccurrenceResults) {
println!(
"Literal occurrences of '{}' ({} in {} file(s)) [source: {}]",
results.query, results.total, results.files_matched, results.source
);
if !results.coverage_line.is_empty() {
println!(" {}", results.coverage_line);
}
if results.total == 0 {
println!(" (not found)");
print_suggested_next(results);
return;
}
print_file_rollup(results);
print_page(results);
print_role_summary(results);
print_file_context(results);
if results.slim {
println!(" (occurrence list suppressed — count_only/slim)");
print_suggested_next(results);
return;
}
for occ in &results.occurrences {
let mut suffix = String::new();
if let Some(definition) = &occ.resolved_definition {
suffix.push_str(&format!(" => {}", definition.symbol_id));
}
if let Some(enclosing) = &occ.enclosing_symbol {
suffix.push_str(&format!(" in {}", enclosing.symbol_id));
}
println!(
" {}:{}:{} [{}] {}{}",
occ.file,
occ.line,
occ.column,
occ.match_role.as_str(),
occ.context,
suffix
);
}
print_suggested_next(results);
}
fn print_file_rollup(results: &OccurrenceResults) {
if let Some(by_file) = &results.by_file {
println!(" by file:");
for fc in by_file {
println!(" {:>5} {}", fc.count, fc.file);
}
}
}
fn print_page(results: &OccurrenceResults) {
if let Some(page) = &results.page {
match page.next_offset {
Some(next) => println!(
" page: offset={}, limit={}, returned={}, next_offset={} (more available)",
page.offset, page.limit, page.returned, next
),
None => println!(
" page: offset={}, limit={}, returned={} (final page)",
page.offset, page.limit, page.returned
),
}
}
}
fn print_literal_find_human(query: &str, literal: &OccurrenceResults, fuzzy: &[FuzzySuggestion]) {
let looks_like_regex = query_has_regex_metachars(query);
println!(
"=== Literal Matches ({} in {} file(s)) [source: {}] ===",
literal.total, literal.files_matched, literal.source
);
if !literal.coverage_line.is_empty() {
println!(" {}", literal.coverage_line);
}
if literal.total == 0 {
if looks_like_regex {
println!(" (0 exact-string matches — NOT a trustworthy absence: the query contains");
println!(" regex metacharacters and `--literal` matches literally, so a pattern was");
println!(" never evaluated. For a regex search use a pattern-aware tool.)");
} else {
println!(" (not found — literal absence is trustworthy)");
}
} else {
if looks_like_regex {
println!(
" (note: matched as an exact string; `--literal` does not evaluate regex metacharacters)"
);
}
print_file_rollup(literal);
print_page(literal);
print_role_summary(literal);
print_file_context(literal);
if literal.slim {
println!(" (occurrence list suppressed — count_only/slim)");
} else {
for occ in &literal.occurrences {
let mut suffix = String::new();
if let Some(definition) = &occ.resolved_definition {
suffix.push_str(&format!(" => {}", definition.symbol_id));
}
if let Some(enclosing) = &occ.enclosing_symbol {
suffix.push_str(&format!(" in {}", enclosing.symbol_id));
}
println!(
" {}:{}:{} [{}] {}{}",
occ.file,
occ.line,
occ.column,
occ.match_role.as_str(),
occ.context,
suffix
);
}
}
}
print_suggested_next(literal);
if !fuzzy.is_empty() {
println!();
println!(
"=== Fuzzy Suggestions ({}) — NOT literal matches, hints only ===",
fuzzy.len()
);
for s in fuzzy {
match s.line {
Some(line) => println!(
" ~ {} (score {:.2}) in {}:{} [source: {}]",
s.symbol, s.score, s.file, line, s.source
),
None => println!(
" ~ {} (score {:.2}) in {} [source: {}]",
s.symbol, s.score, s.file, s.source
),
}
}
}
}
fn print_suggested_next(results: &OccurrenceResults) {
if results.suggested_next.is_empty() {
return;
}
println!(" suggested next:");
for suggestion in &results.suggested_next {
println!(" {} - {}", suggestion.command, suggestion.reason);
}
}
fn print_role_summary(results: &OccurrenceResults) {
let Some(summary) = &results.role_summary else {
return;
};
let mut parts = Vec::new();
if summary.definitions > 0 {
parts.push(format!("{} definition", summary.definitions));
}
if summary.callsites > 0 {
parts.push(format!("{} callsite", summary.callsites));
}
if summary.imports > 0 {
parts.push(format!("{} import", summary.imports));
}
if summary.non_code > 0 {
parts.push(format!("{} non-code", summary.non_code));
}
if summary.other > 0 {
parts.push(format!("{} other", summary.other));
}
if parts.is_empty() {
return;
}
print!(" roles: {}", parts.join(", "));
if !summary.definition_files.is_empty() {
print!(" (defs in: {})", summary.definition_files.join(", "));
}
println!();
}
fn print_file_context(results: &OccurrenceResults) {
if results.file_context.is_empty() {
return;
}
println!(" file context:");
for ctx in &results.file_context {
let mut line = format!(
" {} ({} hit{}, {})",
ctx.file,
ctx.hits,
if ctx.hits == 1 { "" } else { "s" },
ctx.scope_classification.as_str()
);
if !ctx.imported_by.is_empty() {
line.push_str(&format!(" consumers: {}", ctx.imported_by.join(", ")));
}
if !ctx.imports.is_empty() {
line.push_str(&format!(" deps: {}", ctx.imports.join(", ")));
}
if ctx.truncated {
line.push_str(" (…truncated)");
}
println!("{}", line);
}
}
#[cfg(test)]
mod tests {
use super::query_has_regex_metachars;
#[test]
fn regex_metachars_flag_pattern_queries_but_not_plain_literals() {
for pattern in [
r"100\.[0-9]+\.[0-9]+",
r"/Users/[^/]+/",
"foo|bar",
"key.*path",
"a+b",
"(group)",
"name$",
"^anchor",
] {
assert!(
query_has_regex_metachars(pattern),
"pattern-shaped query {pattern:?} must be flagged as regex-like"
);
}
for literal in [
"100.64.0.1",
"package.json",
"run_agent_send_with_fallback",
"BUNDLE_JUNK_EXCLUDES",
"loctree-mcp",
"--version",
] {
assert!(
!query_has_regex_metachars(literal),
"plain literal {literal:?} must not be flagged as regex-like"
);
}
}
}