use crate::analyzer::dead_parrots::{
DeadFilterConfig, SimilarityCandidate, SymbolMatchKind, SymbolSearchResult, find_dead_exports,
find_similar, search_symbol,
};
use crate::colors::Painter;
use crate::types::{ColorMode, FileAnalysis, OutputMode};
use serde::Serialize;
use serde_json::json;
#[derive(Debug, Serialize)]
pub struct ParamMatch {
pub file: String,
pub line: Option<usize>,
pub function: String,
pub param_name: String,
pub param_type: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct SuppressionMatch {
pub file: String,
pub line: usize,
pub suppression_type: String, pub lint_name: String, pub context: String, }
#[derive(Debug, Serialize)]
pub struct SearchResults {
pub query: String,
pub symbol_matches: SymbolSearchResult,
pub param_matches: Vec<ParamMatch>,
pub semantic_matches: Vec<SimilarityCandidate>,
pub dead_status: DeadStatus,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub suppression_matches: Vec<SuppressionMatch>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub cross_matches: Vec<CrossMatchFile>,
}
#[derive(Debug, Serialize)]
pub struct CrossMatchFile {
pub file: String,
pub matched_terms: Vec<CrossMatchTerm>,
}
#[derive(Debug, Clone, Serialize)]
pub enum MatchType {
Export { kind: String },
Import { source: String },
Parameter {
function: String,
param_type: Option<String>,
},
}
#[derive(Debug, Serialize)]
pub struct CrossMatchTerm {
pub term: String,
pub line: usize,
pub context: String,
pub match_type: MatchType,
}
#[derive(Debug, Serialize)]
pub struct DeadStatus {
pub is_exported: bool,
pub is_dead: bool,
pub dead_in_files: Vec<String>,
}
fn search_params(query: &str, analyses: &[FileAnalysis]) -> Vec<ParamMatch> {
let terms: Vec<String> = query.split('|').map(|t| t.trim().to_lowercase()).collect();
let is_multi = terms.len() >= 2;
if !is_multi {
return search_params_flat(&terms[0], analyses);
}
let mut matches = Vec::new();
for analysis in analyses {
for export in &analysis.exports {
let mut matched_terms: std::collections::HashSet<&str> =
std::collections::HashSet::new();
let mut term_params: Vec<ParamMatch> = Vec::new();
let fn_lower = export.name.to_lowercase();
for term in &terms {
if fn_lower.contains(term.as_str()) {
matched_terms.insert(term);
}
}
for param in &export.params {
let name_lower = param.name.to_lowercase();
let type_lower = param
.type_annotation
.as_ref()
.map(|t| t.to_lowercase())
.unwrap_or_default();
for term in &terms {
if name_lower.contains(term.as_str()) || type_lower.contains(term.as_str()) {
matched_terms.insert(term);
term_params.push(ParamMatch {
file: analysis.path.clone(),
line: export.line,
function: export.name.clone(),
param_name: param.name.clone(),
param_type: param.type_annotation.clone(),
});
}
}
}
if matched_terms.len() >= 2 {
matches.extend(term_params);
}
}
}
matches
}
fn search_params_flat(term: &str, analyses: &[FileAnalysis]) -> Vec<ParamMatch> {
let mut matches = Vec::new();
for analysis in analyses {
for export in &analysis.exports {
for param in &export.params {
let name_lower = param.name.to_lowercase();
if name_lower.contains(term) {
matches.push(ParamMatch {
file: analysis.path.clone(),
line: export.line,
function: export.name.clone(),
param_name: param.name.clone(),
param_type: param.type_annotation.clone(),
});
}
}
}
}
matches
}
fn search_suppressions(query: &str, analyses: &[FileAnalysis]) -> Vec<SuppressionMatch> {
use regex::Regex;
use std::fs;
let query_lower = query.to_lowercase();
let mut matches = Vec::new();
let rust_allow_re = Regex::new(r"#\[(allow|deny|warn)\(([^)]+)\)\]").unwrap();
let ts_ignore_re = Regex::new(r"@ts-(ignore|expect-error)").unwrap();
let eslint_re = Regex::new(r"eslint-disable(-next-line|-line)?(\s+[\w-]+)?").unwrap();
let python_noqa_re = Regex::new(r"#\s*(noqa|type:\s*ignore)").unwrap();
for analysis in analyses {
let content = match fs::read_to_string(&analysis.path) {
Ok(c) => c,
Err(_) => continue,
};
for (line_num, line) in content.lines().enumerate() {
let line_lower = line.to_lowercase();
let line_trimmed = line.trim();
if line_trimmed.starts_with("//") {
continue;
}
if let Some(caps) = rust_allow_re.captures(line) {
let directive = caps.get(1).map(|m| m.as_str()).unwrap_or("");
let lints = caps.get(2).map(|m| m.as_str()).unwrap_or("");
for lint in lints.split(',') {
let lint = lint.trim();
let lint_lower = lint.to_lowercase();
if lint_lower.contains(&query_lower)
|| lint_lower.replace("clippy::", "").contains(&query_lower)
{
matches.push(SuppressionMatch {
file: analysis.path.clone(),
line: line_num + 1,
suppression_type: format!("rust_{}", directive),
lint_name: lint.to_string(),
context: line.trim().to_string(),
});
}
}
}
if ts_ignore_re.is_match(line)
&& query_lower.contains("ts-")
&& let Some(caps) = ts_ignore_re.captures(line)
{
let kind = caps.get(1).map(|m| m.as_str()).unwrap_or("ignore");
matches.push(SuppressionMatch {
file: analysis.path.clone(),
line: line_num + 1,
suppression_type: "ts_directive".to_string(),
lint_name: format!("ts-{}", kind),
context: line.trim().to_string(),
});
}
if line_lower.contains("eslint-disable")
&& query_lower.contains("eslint")
&& let Some(caps) = eslint_re.captures(line)
{
let rule = caps
.get(2)
.map(|m| m.as_str().trim())
.unwrap_or("all")
.to_string();
matches.push(SuppressionMatch {
file: analysis.path.clone(),
line: line_num + 1,
suppression_type: "eslint_disable".to_string(),
lint_name: rule,
context: line.trim().to_string(),
});
}
if python_noqa_re.is_match(line)
&& (query_lower.contains("noqa") || query_lower.contains("type"))
&& let Some(caps) = python_noqa_re.captures(line)
{
let kind = caps.get(1).map(|m| m.as_str()).unwrap_or("noqa");
matches.push(SuppressionMatch {
file: analysis.path.clone(),
line: line_num + 1,
suppression_type: "python_suppress".to_string(),
lint_name: kind.to_string(),
context: line.trim().to_string(),
});
}
}
}
matches
}
fn normalize_query(query: &str) -> String {
if query.contains('|') {
query.to_string()
} else {
let tokens: Vec<&str> = query.split_whitespace().filter(|t| t.len() >= 2).collect();
if tokens.is_empty() {
query.to_string()
} else if tokens.len() == 1 {
tokens[0].to_string()
} else {
tokens.join("|")
}
}
}
pub fn run_search(query: &str, analyses: &[FileAnalysis]) -> SearchResults {
let query = normalize_query(query);
let symbol_matches = search_symbol(&query, analyses);
let param_matches = search_params(&query, analyses);
let semantic_matches = find_similar(&query, analyses);
let all_dead = find_dead_exports(analyses, false, None, DeadFilterConfig::default());
let dead_for_query: Vec<_> = all_dead
.iter()
.filter(|d| d.symbol.to_lowercase().contains(&query.to_lowercase()))
.collect();
let is_exported = !symbol_matches.files.is_empty()
|| analyses.iter().any(|a| {
a.exports
.iter()
.any(|e| e.name.to_lowercase().contains(&query.to_lowercase()))
});
let dead_status = DeadStatus {
is_exported,
is_dead: !dead_for_query.is_empty(),
dead_in_files: dead_for_query.iter().map(|d| d.file.clone()).collect(),
};
let suppression_matches = search_suppressions(&query, analyses);
let cross_matches = if query.contains('|') {
compute_cross_matches(&query, analyses)
} else {
vec![]
};
SearchResults {
query: query.to_string(),
symbol_matches,
param_matches,
semantic_matches,
dead_status,
suppression_matches,
cross_matches,
}
}
fn compute_cross_matches(query: &str, analyses: &[FileAnalysis]) -> Vec<CrossMatchFile> {
use std::collections::HashMap;
let terms: Vec<&str> = query.split('|').filter(|t| !t.is_empty()).collect();
if terms.len() < 2 {
return vec![];
}
let mut file_matches: HashMap<String, Vec<CrossMatchTerm>> = HashMap::new();
for analysis in analyses {
for term in &terms {
let term_lower = term.to_lowercase();
for exp in &analysis.exports {
if exp.name.to_lowercase().contains(&term_lower) {
file_matches
.entry(analysis.path.clone())
.or_default()
.push(CrossMatchTerm {
term: term.to_string(),
line: exp.line.unwrap_or(0),
context: format!("{} {}", exp.kind, exp.name),
match_type: MatchType::Export {
kind: exp.kind.clone(),
},
});
}
}
for imp in &analysis.imports {
for sym in &imp.symbols {
if sym.name.to_lowercase().contains(&term_lower) {
file_matches.entry(analysis.path.clone()).or_default().push(
CrossMatchTerm {
term: term.to_string(),
line: imp.line.unwrap_or(0),
context: format!("import {} from {}", sym.name, imp.source),
match_type: MatchType::Import {
source: imp.source.clone(),
},
},
);
}
}
}
for exp in &analysis.exports {
for param in &exp.params {
if param.name.to_lowercase().contains(&term_lower) {
file_matches.entry(analysis.path.clone()).or_default().push(
CrossMatchTerm {
term: term.to_string(),
line: exp.line.unwrap_or(0),
context: format!(
"{}({}: {})",
exp.name,
param.name,
param.type_annotation.as_deref().unwrap_or("?")
),
match_type: MatchType::Parameter {
function: exp.name.clone(),
param_type: param.type_annotation.clone(),
},
},
);
}
if let Some(typ) = ¶m.type_annotation
&& typ.to_lowercase().contains(&term_lower)
{
file_matches.entry(analysis.path.clone()).or_default().push(
CrossMatchTerm {
term: term.to_string(),
line: exp.line.unwrap_or(0),
context: format!("{}({}: {})", exp.name, param.name, typ),
match_type: MatchType::Parameter {
function: exp.name.clone(),
param_type: Some(typ.clone()),
},
},
);
}
}
}
}
}
let mut results: Vec<CrossMatchFile> = file_matches
.into_iter()
.filter_map(|(file, matches)| {
let unique_terms: std::collections::HashSet<_> =
matches.iter().map(|m| &m.term).collect();
if unique_terms.len() >= 2 {
Some(CrossMatchFile {
file,
matched_terms: matches,
})
} else {
None
}
})
.collect();
results.sort_by(|a, b| b.matched_terms.len().cmp(&a.matched_terms.len()));
results
}
pub fn print_search_results(
results: &SearchResults,
output: OutputMode,
symbol_only: bool,
dead_only: bool,
semantic_only: bool,
color: ColorMode,
) {
if matches!(output, OutputMode::Json) {
print_search_json(results, symbol_only, dead_only, semantic_only);
return;
}
if matches!(output, OutputMode::Jsonl) {
print_search_jsonl(results, symbol_only, dead_only, semantic_only);
return;
}
let is_multi = results.query.contains('|');
if is_multi && !results.cross_matches.is_empty() {
print_search_multiterm(results, symbol_only, dead_only, semantic_only, color);
} else {
print_search_single(results, symbol_only, dead_only, semantic_only, color);
}
}
fn print_search_multiterm(
results: &SearchResults,
symbol_only: bool,
dead_only: bool,
semantic_only: bool,
color: ColorMode,
) {
let p = Painter::new(color);
println!("Search results for: {}\n", results.query);
let cross_files: std::collections::HashSet<&str> = results
.cross_matches
.iter()
.map(|cm| cm.file.as_str())
.collect();
if !dead_only && !semantic_only {
println!(
"=== Cross-Match Files ({}) ===",
results.cross_matches.len()
);
println!(" Files containing 2+ different query terms:\n");
for cm in &results.cross_matches {
let mut term_summary: std::collections::BTreeMap<&str, [usize; 3]> =
std::collections::BTreeMap::new();
for t in &cm.matched_terms {
let counts = term_summary.entry(&t.term).or_insert([0, 0, 0]);
match &t.match_type {
MatchType::Export { .. } => counts[0] += 1,
MatchType::Import { .. } => counts[1] += 1,
MatchType::Parameter { .. } => counts[2] += 1,
}
}
println!(" {} ({} terms)", p.path(&cm.file), term_summary.len());
for (term, counts) in &term_summary {
let mut parts = Vec::new();
if counts[0] > 0 {
parts.push(format!("{} exports", counts[0]));
}
if counts[1] > 0 {
parts.push(format!("{} imports", counts[1]));
}
if counts[2] > 0 {
parts.push(format!("{} params", counts[2]));
}
println!(" ├─ {}: {}", term, parts.join(", "));
}
}
println!();
let filtered_count: usize = results
.symbol_matches
.files
.iter()
.filter(|f| cross_files.contains(f.file.as_str()))
.map(|f| f.matches.len())
.sum();
if filtered_count > 0 {
println!(
"=== Symbol Matches ({} in cross-match files, {} total) ===",
filtered_count, results.symbol_matches.total_matches
);
let mut definitions = Vec::new();
let mut imports = Vec::new();
let mut usages = Vec::new();
for file_match in &results.symbol_matches.files {
if !cross_files.contains(file_match.file.as_str()) {
continue;
}
for m in &file_match.matches {
match m.kind {
SymbolMatchKind::Definition => definitions.push((&file_match.file, m)),
SymbolMatchKind::Import => imports.push((&file_match.file, m)),
SymbolMatchKind::Usage => usages.push((&file_match.file, m)),
}
}
}
print_symbol_sections(&p, &definitions, &imports, &usages);
} else {
println!(
"=== Symbol Matches ({} total, none in cross-match files) ===\n",
results.symbol_matches.total_matches
);
}
}
if !dead_only && !semantic_only && !results.param_matches.is_empty() {
print_param_matches(&results.param_matches);
}
if !dead_only && !semantic_only && !results.suppression_matches.is_empty() {
print_suppression_matches(&results.suppression_matches);
}
print_semantic_and_dead(results, symbol_only, dead_only, semantic_only, &p);
}
fn print_search_single(
results: &SearchResults,
symbol_only: bool,
dead_only: bool,
semantic_only: bool,
color: ColorMode,
) {
let p = Painter::new(color);
println!("Search results for: {}\n", results.query);
if !dead_only && !semantic_only {
println!(
"=== Symbol Matches ({}) ===",
results.symbol_matches.total_matches
);
if results.symbol_matches.files.is_empty() {
println!(" No symbol matches found.\n");
} else {
let mut definitions = Vec::new();
let mut imports = Vec::new();
let mut usages = Vec::new();
for file_match in &results.symbol_matches.files {
for m in &file_match.matches {
match m.kind {
SymbolMatchKind::Definition => definitions.push((&file_match.file, m)),
SymbolMatchKind::Import => imports.push((&file_match.file, m)),
SymbolMatchKind::Usage => usages.push((&file_match.file, m)),
}
}
}
print_symbol_sections(&p, &definitions, &imports, &usages);
}
}
if !dead_only && !semantic_only && !results.param_matches.is_empty() {
print_param_matches(&results.param_matches);
}
if !dead_only && !semantic_only && !results.suppression_matches.is_empty() {
print_suppression_matches(&results.suppression_matches);
}
print_semantic_and_dead(results, symbol_only, dead_only, semantic_only, &p);
}
fn print_symbol_sections(
p: &Painter,
definitions: &[(&String, &crate::analyzer::dead_parrots::SymbolMatch)],
imports: &[(&String, &crate::analyzer::dead_parrots::SymbolMatch)],
usages: &[(&String, &crate::analyzer::dead_parrots::SymbolMatch)],
) {
if !definitions.is_empty() {
println!(
"{}",
p.header("── Definition ──────────────────────────────────────────────")
);
for (file, m) in definitions {
let location = if m.line > 0 {
format!("{}:{}", file, m.line)
} else {
file.to_string()
};
println!(" {} {} {}", p.ok("[DEF]"), p.path(&location), m.context);
}
println!();
}
if !imports.is_empty() {
let header = format!(
"── Imports ({}) ────────────────────────────────────────────",
imports.len()
);
println!("{}", p.header(&header));
for (file, m) in imports {
let location = if m.line > 0 {
format!("{}:{}", file, m.line)
} else {
file.to_string()
};
println!(" {} {} {}", p.info("[IMP]"), p.path(&location), m.context);
}
println!();
}
if !usages.is_empty() {
let header = format!(
"── Usages ({}) ─────────────────────────────────────────────",
usages.len()
);
println!("{}", p.header(&header));
for (file, m) in usages {
let location = if m.line > 0 {
format!("{}:{}", file, m.line)
} else {
file.to_string()
};
println!(
" {} {} {}",
p.symbol("[USE]"),
p.path(&location),
m.context
);
}
println!();
}
println!();
}
fn print_param_matches(params: &[ParamMatch]) {
println!("=== Parameter Matches ({}) ===", params.len());
for pm in params {
let type_info = pm
.param_type
.as_ref()
.map(|t| format!(": {}", t))
.unwrap_or_default();
let line_info = pm.line.map(|l| format!(":{}", l)).unwrap_or_default();
println!(
" {}{} - {}{} in {}()",
pm.file, line_info, pm.param_name, type_info, pm.function
);
}
println!();
}
fn print_suppression_matches(suppressions: &[SuppressionMatch]) {
println!("=== Lint Suppressions ({}) ===", suppressions.len());
for sm in suppressions {
println!(
" {}:{} [{}] {}",
sm.file, sm.line, sm.lint_name, sm.context
);
}
println!();
}
fn print_semantic_and_dead(
results: &SearchResults,
symbol_only: bool,
dead_only: bool,
semantic_only: bool,
p: &Painter,
) {
if !dead_only && !symbol_only {
println!(
"=== Semantic Matches ({}) ===",
results.semantic_matches.len()
);
if results.semantic_matches.is_empty() {
println!(" No semantic matches found.\n");
} else {
for candidate in &results.semantic_matches {
println!(" {} (score: {:.2})", candidate.symbol, candidate.score);
println!(" in {}", p.path(&candidate.file));
}
println!();
}
}
if !symbol_only && !semantic_only {
println!("=== Dead Code Status ===");
if !results.dead_status.is_exported {
println!(" Symbol not found as export.\n");
} else if results.dead_status.is_dead {
println!(" WARNING: Symbol appears to be dead code in:");
for file in &results.dead_status.dead_in_files {
println!(" - {}", file);
}
println!();
} else {
println!(" OK: Symbol is used.\n");
}
}
}
fn print_search_json(
results: &SearchResults,
symbol_only: bool,
dead_only: bool,
semantic_only: bool,
) {
let output = if symbol_only {
json!({
"query": results.query,
"symbol_matches": results.symbol_matches,
"param_matches": results.param_matches,
})
} else if dead_only {
json!({
"query": results.query,
"dead_status": results.dead_status,
})
} else if semantic_only {
json!({
"query": results.query,
"semantic_matches": results.semantic_matches,
})
} else {
json!({
"query": results.query,
"symbol_matches": results.symbol_matches,
"param_matches": results.param_matches,
"semantic_matches": results.semantic_matches,
"suppression_matches": results.suppression_matches,
"cross_matches": results.cross_matches,
"dead_status": results.dead_status,
})
};
println!("{}", serde_json::to_string_pretty(&output).unwrap());
}
fn print_search_jsonl(
results: &SearchResults,
symbol_only: bool,
dead_only: bool,
semantic_only: bool,
) {
if !dead_only && !semantic_only {
println!(
"{}",
json!({"type": "symbol_matches", "data": results.symbol_matches})
);
if !results.param_matches.is_empty() {
println!(
"{}",
json!({"type": "param_matches", "data": results.param_matches})
);
}
if !results.suppression_matches.is_empty() {
println!(
"{}",
json!({"type": "suppression_matches", "data": results.suppression_matches})
);
}
if !results.cross_matches.is_empty() {
println!(
"{}",
json!({"type": "cross_matches", "data": results.cross_matches})
);
}
}
if !dead_only && !symbol_only {
println!(
"{}",
json!({"type": "semantic_matches", "data": results.semantic_matches})
);
}
if !symbol_only && !semantic_only {
println!(
"{}",
json!({"type": "dead_status", "data": results.dead_status})
);
}
}