use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use std::fs;
use std::path::{Path, PathBuf};
use crate::cache::OutlineCache;
use crate::error::SrcwalkError;
use crate::format::rel_nonempty;
use crate::lang::detect_file_type;
use crate::lang::outline::{extract_import_source, get_outline_entries};
use crate::read::imports::{is_external, is_import_line, resolve_related_files_with_content};
use crate::search::callees::{extract_callee_names, resolve_callees};
use crate::search::callers::find_callers_batch;
use crate::types::{FileType, OutlineKind};
const MAX_EXPORTED_SYMBOLS: usize = 25;
const MAX_DEPENDENTS: usize = 15;
pub struct DepsResult {
pub target: PathBuf,
pub uses_local: Vec<LocalDep>,
pub uses_external: Vec<String>,
pub used_by: Vec<Dependent>,
pub total_dependents: usize,
pub exported_count: usize,
pub searched_count: usize,
}
pub struct LocalDep {
pub path: PathBuf,
pub symbols: Vec<String>,
}
pub struct Dependent {
pub path: PathBuf,
pub symbols: Vec<(String, String, u32)>,
pub is_test: bool,
}
pub fn analyze_deps(
path: &Path,
scope: &Path,
cache: &OutlineCache,
bloom: &crate::index::bloom::BloomFilterCache,
) -> Result<DepsResult, SrcwalkError> {
let path = &path.canonicalize().map_err(|e| SrcwalkError::IoError {
path: path.to_path_buf(),
source: e,
})?;
let content = fs::read_to_string(path).map_err(|e| SrcwalkError::IoError {
path: path.clone(),
source: e,
})?;
let FileType::Code(lang) = detect_file_type(path) else {
return Ok(DepsResult {
target: path.clone(),
uses_local: Vec::new(),
uses_external: Vec::new(),
used_by: Vec::new(),
total_dependents: 0,
exported_count: 0,
searched_count: 0,
});
};
let entries = get_outline_entries(&content, lang);
let _ = cache;
let mut all_names: Vec<String> = Vec::new();
for entry in &entries {
if matches!(entry.kind, OutlineKind::Import | OutlineKind::Export) {
continue;
}
collect_symbol_names(entry, &mut all_names);
}
all_names.sort();
all_names.dedup();
all_names.retain(|n| !is_placeholder_name(n));
let exported_count = all_names.len();
let searched_count = if all_names.len() > MAX_EXPORTED_SYMBOLS {
all_names.sort_by(|a, b| b.len().cmp(&a.len()).then_with(|| a.cmp(b)));
all_names.truncate(MAX_EXPORTED_SYMBOLS);
MAX_EXPORTED_SYMBOLS
} else {
all_names.len()
};
let callee_names = extract_callee_names(&content, lang, None);
let resolved = resolve_callees(&callee_names, path, &content, cache, bloom);
let mut local_by_file: HashMap<PathBuf, Vec<String>> = HashMap::new();
for callee in resolved {
if callee.file != *path {
local_by_file
.entry(callee.file)
.or_default()
.push(callee.name);
}
}
let import_files = resolve_related_files_with_content(path, &content);
for import_path in import_files {
local_by_file.entry(import_path).or_default();
}
let mut uses_local: Vec<LocalDep> = local_by_file
.into_iter()
.map(|(dep_path, mut syms)| {
syms.sort();
syms.dedup();
LocalDep {
path: dep_path,
symbols: syms,
}
})
.collect();
uses_local.sort_by(|a, b| a.path.cmp(&b.path));
let mut external_set: HashSet<String> = HashSet::new();
for line in content.lines() {
if !is_import_line(line, lang) {
continue;
}
let source = extract_import_source(line, Some(lang));
if source.is_empty() {
continue;
}
if is_external(&source, lang) && !is_stdlib(&source, lang) && is_valid_module_path(&source)
{
external_set.insert(source.clone());
}
}
let mut uses_external: Vec<String> = external_set.into_iter().collect();
uses_external.sort();
let mut used_by = if searched_count > 0 {
let symbols_set: HashSet<String> = all_names.iter().cloned().collect();
let raw_matches = find_callers_batch(&symbols_set, scope, bloom, None, None, Some(50))?;
let mut by_file: HashMap<PathBuf, Vec<(String, String, u32)>> = HashMap::new();
for (matched_symbol, caller_match) in raw_matches {
if caller_match.path == *path {
continue;
}
by_file.entry(caller_match.path).or_default().push((
caller_match.calling_function,
matched_symbol,
caller_match.line,
));
}
let target_dir = path.parent();
let mut dependents: Vec<Dependent> = by_file
.into_iter()
.map(|(dep_path, mut pairs)| {
pairs.sort();
pairs.dedup();
let is_test = is_test_file(&dep_path);
Dependent {
path: dep_path,
symbols: pairs,
is_test,
}
})
.collect();
dependents.sort_by(|a, b| {
let a_same_dir = target_dir.is_some_and(|d| a.path.parent() == Some(d));
let b_same_dir = target_dir.is_some_and(|d| b.path.parent() == Some(d));
b_same_dir
.cmp(&a_same_dir)
.then_with(|| a.is_test.cmp(&b.is_test))
.then_with(|| a.path.cmp(&b.path))
});
dependents
} else {
Vec::new()
};
let total_dependents = used_by.len();
used_by.truncate(MAX_DEPENDENTS);
Ok(DepsResult {
target: path.clone(),
uses_local,
uses_external,
used_by,
total_dependents,
exported_count,
searched_count,
})
}
pub fn format_deps(result: &DepsResult, scope: &Path, budget: Option<usize>) -> String {
let dep_count = result.total_dependents;
let (prod_deps, test_deps): (Vec<_>, Vec<_>) = result.used_by.iter().partition(|d| !d.is_test);
let rel_target = rel_nonempty(&result.target, scope);
let header = format!(
"# Deps: {} — {} local, {} external, {} dependent{}",
rel_target,
result.uses_local.len(),
result.uses_external.len(),
dep_count,
if dep_count == 1 { "" } else { "s" },
);
let uses_local_section = format_uses_local(&result.uses_local, scope, true);
let uses_external_section = format_uses_external(&result.uses_external);
let used_by_section = format_used_by(&prod_deps, scope, "## Used by");
let used_by_tests_section = format_used_by(&test_deps, scope, "## Used by (tests)");
let barrel_note = if result.exported_count > MAX_EXPORTED_SYMBOLS {
format!(
"\n\n> ({} of {} exports shown — barrel file detected)",
result.searched_count, result.exported_count
)
} else {
String::new()
};
let mut parts: Vec<String> = Vec::new();
parts.push(header.clone());
if !uses_local_section.is_empty() {
parts.push(uses_local_section.clone());
}
if !uses_external_section.is_empty() {
parts.push(uses_external_section.clone());
}
if !used_by_section.is_empty() {
parts.push(used_by_section.clone());
}
if !used_by_tests_section.is_empty() {
parts.push(used_by_tests_section.clone());
}
let truncated = result.total_dependents.saturating_sub(result.used_by.len());
if truncated > 0 {
parts.push(format!("... and {truncated} more dependents"));
}
if !barrel_note.is_empty() {
parts.push(barrel_note.clone());
}
let full = parts.join("\n\n");
let full_tokens = crate::types::estimate_tokens(full.len() as u64) as usize;
let output = match budget {
None => full,
Some(b) if full_tokens <= b => full,
Some(b) => {
apply_budget_truncation(
&header,
&uses_local_section,
&uses_external_section,
&prod_deps,
&test_deps,
&barrel_note,
scope,
b,
)
}
};
let token_est = crate::types::estimate_tokens(output.len() as u64);
format!("{output}\n\n[~{token_est} tokens]")
}
fn collect_symbol_names(entry: &crate::types::OutlineEntry, out: &mut Vec<String>) {
out.push(entry.name.clone());
for child in &entry.children {
if !matches!(child.kind, OutlineKind::Import | OutlineKind::Export) {
out.push(child.name.clone());
}
}
}
fn is_placeholder_name(name: &str) -> bool {
if name == "<anonymous>" {
return true;
}
if name.starts_with('<') {
return true;
}
if name.starts_with("impl ") {
return true;
}
if name.chars().count() == 1 {
return true;
}
false
}
fn is_stdlib(source: &str, lang: crate::types::Lang) -> bool {
use crate::types::Lang;
match lang {
Lang::Rust => {
source.starts_with("std::")
|| source.starts_with("core::")
|| source.starts_with("alloc::")
}
Lang::Python => {
matches!(
source.split('.').next().unwrap_or(""),
"os" | "sys"
| "re"
| "json"
| "math"
| "time"
| "datetime"
| "pathlib"
| "typing"
| "collections"
| "functools"
| "itertools"
| "abc"
| "io"
| "logging"
| "unittest"
| "dataclasses"
| "enum"
| "copy"
| "hashlib"
| "subprocess"
| "threading"
| "asyncio"
)
}
Lang::Go => source.starts_with("fmt") || !source.contains('.'),
_ => false,
}
}
fn is_valid_module_path(source: &str) -> bool {
if source.contains(' ') {
return false;
}
source
.chars()
.next()
.is_some_and(|c| c.is_alphanumeric() || c == '@' || c == '.')
}
use crate::types::is_test_file;
fn format_uses_local(deps: &[LocalDep], scope: &Path, with_symbols: bool) -> String {
if deps.is_empty() {
return String::new();
}
let mut out = String::from("## Uses (local)");
for dep in deps {
let rel = rel_nonempty(&dep.path, scope);
if with_symbols && !dep.symbols.is_empty() {
let _ = write!(out, "\n{:<30} {}", rel, dep.symbols.join(", "));
} else {
let _ = write!(out, "\n{rel}");
}
}
out
}
fn format_uses_external(externals: &[String]) -> String {
if externals.is_empty() {
return String::new();
}
let mut out = String::from("## Uses (external)");
for ext in externals {
let _ = write!(out, "\n{ext}");
}
out
}
fn format_used_by(deps: &[&Dependent], scope: &Path, heading: &str) -> String {
if deps.is_empty() {
return String::new();
}
let mut out = String::from(heading);
for dep in deps {
let rel = rel_nonempty(&dep.path, scope);
let mut by_caller: HashMap<&str, (u32, Vec<&str>)> = HashMap::new();
for (caller, symbol, line) in &dep.symbols {
let entry = by_caller
.entry(caller.as_str())
.or_insert((*line, Vec::new()));
entry.0 = entry.0.min(*line);
entry.1.push(symbol.as_str());
}
let mut callers: Vec<(&str, u32, Vec<&str>)> = by_caller
.into_iter()
.map(|(caller, (line, syms))| (caller, line, syms))
.collect();
callers.sort_unstable_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(b.0)));
for (caller, line, syms) in callers {
let loc = format!("{rel}:{line}");
let joined = syms.join(", ");
let _ = write!(out, "\n{loc:<30} {caller:<20} \u{2192} {joined}");
}
}
out
}
#[allow(clippy::too_many_arguments)]
fn apply_budget_truncation(
header: &str,
uses_local_full: &str,
uses_external_full: &str,
prod_deps: &[&Dependent],
test_deps: &[&Dependent],
barrel_note: &str,
scope: &Path,
budget: usize,
) -> String {
#[allow(clippy::type_complexity)]
let candidates: &[fn(
&str,
&str,
&str,
&[&Dependent],
&[&Dependent],
&str,
&Path,
) -> String] = &[
|hdr, ul, ue, pd, _td, bn, sc| {
assemble(&[hdr, ul, ue, &format_used_by(pd, sc, "## Used by"), bn])
},
|hdr, ul, ue, pd, _td, bn, _sc| {
let count = pd.len();
let note = if count > 0 {
format!("\n\n(... {count} more dependents)")
} else {
String::new()
};
assemble(&[hdr, ul, ue, ¬e, bn])
},
|hdr, ul, _ue, _pd, _td, bn, _sc| assemble(&[hdr, ul, bn]),
|hdr, ul, _ue, _pd, _td, _bn, _sc| {
let local_lines: Vec<&str> = ul
.lines()
.skip(1) .map(|l| l.split_whitespace().next().unwrap_or(l))
.collect();
let paths_only = if local_lines.is_empty() {
String::new()
} else {
format!("## Uses (local)\n{}", local_lines.join("\n"))
};
assemble(&[hdr, &paths_only])
},
|hdr, _ul, _ue, _pd, _td, _bn, _sc| hdr.to_string(),
];
for candidate_fn in candidates {
let candidate = candidate_fn(
header,
uses_local_full,
uses_external_full,
prod_deps,
test_deps,
barrel_note,
scope,
);
let tokens = crate::types::estimate_tokens(candidate.len() as u64) as usize;
if tokens <= budget {
return candidate;
}
}
header.to_string()
}
fn assemble(parts: &[&str]) -> String {
parts
.iter()
.filter(|s| !s.trim().is_empty())
.copied()
.collect::<Vec<_>>()
.join("\n\n")
}