use rustc_hash::{FxHashMap, FxHashSet};
use fallow_types::extract::ModuleInfo;
use crate::discover::FileId;
use crate::graph::ModuleGraph;
use crate::resolve::ResolvedModule;
use crate::results::{RenderFanInComponent, RenderFanInMetric};
use super::react_resolve::{ChildResolver, CompKey};
const CONCENTRATION_FLOOR: u32 = 10;
#[must_use]
pub fn compute_render_fan_in(
graph: &ModuleGraph,
modules: &[ModuleInfo],
resolved_modules: &[ResolvedModule],
declared_deps: &FxHashSet<String>,
root: &std::path::Path,
) -> Option<RenderFanInMetric> {
let gated = declared_deps.contains("react")
|| declared_deps.contains("react-dom")
|| declared_deps.contains("next")
|| declared_deps.contains("preact");
if !gated {
return None;
}
let is_test_anchor = |path: &std::path::Path| -> bool {
let rel = path.strip_prefix(root).unwrap_or(path);
super::predicates::is_test_or_spec_file(rel)
};
let modules_by_id: FxHashMap<FileId, &ModuleInfo> =
modules.iter().map(|m| (m.file_id, m)).collect();
let resolved_by_id: FxHashMap<FileId, &ResolvedModule> =
resolved_modules.iter().map(|m| (m.file_id, m)).collect();
let resolver = ChildResolver::new(graph, &modules_by_id, &resolved_by_id);
let mut counts: FxHashMap<CompKey, FanInAccum> = FxHashMap::default();
for node in &graph.modules {
if !node.is_reachable() || !is_react_file(&node.path) {
continue;
}
if is_test_anchor(&node.path) {
continue;
}
let Some(module) = modules_by_id.get(&node.file_id) else {
continue;
};
for func in &module.component_functions {
counts
.entry(CompKey {
file: node.file_id,
name: func.name.clone(),
})
.or_default();
}
}
for node in &graph.modules {
if !node.is_reachable() || !is_react_file(&node.path) {
continue;
}
if is_test_anchor(&node.path) {
continue;
}
let Some(module) = modules_by_id.get(&node.file_id) else {
continue;
};
for edge in &module.render_edges {
let Some(child_key) = resolver.resolve(node.file_id, &edge.child_component_name) else {
continue;
};
let accum = counts.entry(child_key).or_default();
accum.render_sites += 1;
accum
.parents
.insert((node.file_id, edge.parent_component.clone()));
}
}
let path_by_id: FxHashMap<FileId, &std::path::Path> = graph
.modules
.iter()
.map(|node| (node.file_id, node.path.as_path()))
.collect();
let mut per_component: Vec<RenderFanInComponent> = Vec::with_capacity(counts.len());
let mut distinct_parents_dist: Vec<u32> = Vec::with_capacity(counts.len());
let mut max_distinct_parents: u32 = 0;
for (key, accum) in &counts {
let Some(path) = path_by_id.get(&key.file) else {
continue;
};
let distinct_parents = u32::try_from(accum.parents.len()).unwrap_or(u32::MAX);
max_distinct_parents = max_distinct_parents.max(distinct_parents);
distinct_parents_dist.push(distinct_parents);
per_component.push(RenderFanInComponent {
file: (*path).to_path_buf(),
component: key.name.clone(),
render_sites: accum.render_sites,
distinct_parents,
});
}
if per_component.is_empty() {
return None;
}
per_component.sort_by(|a, b| {
a.file
.cmp(&b.file)
.then_with(|| a.component.cmp(&b.component))
});
let (p95_distinct_parents, high_pct) = concentration(&distinct_parents_dist);
Some(RenderFanInMetric {
per_component,
p95_distinct_parents,
high_pct,
max_distinct_parents: Some(max_distinct_parents),
})
}
#[derive(Default)]
struct FanInAccum {
render_sites: u32,
parents: FxHashSet<(FileId, String)>,
}
#[expect(
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_sign_loss,
reason = "distinct-parents values are bounded by project size; mirrors compute_coupling_concentration"
)]
fn concentration(distinct_parents: &[u32]) -> (Option<u32>, Option<f64>) {
if distinct_parents.is_empty() {
return (None, None);
}
let mut sorted: Vec<u32> = distinct_parents.to_vec();
sorted.sort_unstable();
let idx = (sorted.len() as f64 * 0.95).ceil() as usize;
let idx = idx.min(sorted.len()) - 1;
let p95 = sorted[idx];
let threshold = p95.max(CONCENTRATION_FLOOR);
let high_count = sorted.iter().filter(|&&fi| fi > threshold).count();
let high_pct = (high_count as f64 / sorted.len() as f64 * 1000.0).round() / 10.0;
(Some(p95), Some(high_pct))
}
fn is_react_file(path: &std::path::Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("jsx" | "tsx")
)
}