use std::path::Path;
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: &Path,
) -> Option<RenderFanInMetric> {
if !project_declares_react(declared_deps) {
return None;
}
let (modules_by_id, resolved_by_id) = build_lookup_maps(modules, resolved_modules);
let resolver = ChildResolver::new(graph, &modules_by_id, &resolved_by_id);
let mut counts = seed_component_population(graph, &modules_by_id, root);
credit_render_edges(graph, &modules_by_id, &resolver, root, &mut counts);
build_render_fan_in_metric(graph, counts)
}
fn project_declares_react(declared_deps: &FxHashSet<String>) -> bool {
declared_deps.contains("react")
|| declared_deps.contains("react-dom")
|| declared_deps.contains("next")
|| declared_deps.contains("preact")
}
fn build_lookup_maps<'a>(
modules: &'a [ModuleInfo],
resolved_modules: &'a [ResolvedModule],
) -> (
FxHashMap<FileId, &'a ModuleInfo>,
FxHashMap<FileId, &'a ResolvedModule>,
) {
(
modules.iter().map(|m| (m.file_id, m)).collect(),
resolved_modules.iter().map(|m| (m.file_id, m)).collect(),
)
}
fn seed_component_population(
graph: &ModuleGraph,
modules_by_id: &FxHashMap<FileId, &ModuleInfo>,
root: &Path,
) -> FxHashMap<CompKey, FanInAccum> {
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_project_test_path(&node.path, root) {
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();
}
}
counts
}
fn credit_render_edges(
graph: &ModuleGraph,
modules_by_id: &FxHashMap<FileId, &ModuleInfo>,
resolver: &ChildResolver<'_>,
root: &Path,
counts: &mut FxHashMap<CompKey, FanInAccum>,
) {
for node in &graph.modules {
if !node.is_reachable() || !is_react_file(&node.path) {
continue;
}
if is_project_test_path(&node.path, root) {
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()));
}
}
}
fn build_render_fan_in_metric(
graph: &ModuleGraph,
counts: FxHashMap<CompKey, FanInAccum>,
) -> Option<RenderFanInMetric> {
let path_by_id: FxHashMap<FileId, &Path> = graph
.modules
.iter()
.map(|node| (node.file_id, node.path.as_path()))
.collect();
let parts = collect_render_fan_in_components(&path_by_id, counts);
if parts.per_component.is_empty() {
return None;
}
let (p95_distinct_parents, high_pct) = concentration(&parts.distinct_parents_dist);
Some(RenderFanInMetric {
per_component: parts.per_component,
p95_distinct_parents,
high_pct,
max_distinct_parents: Some(parts.max_distinct_parents),
})
}
struct RenderFanInMetricParts {
per_component: Vec<RenderFanInComponent>,
distinct_parents_dist: Vec<u32>,
max_distinct_parents: u32,
}
fn collect_render_fan_in_components(
path_by_id: &FxHashMap<FileId, &Path>,
counts: FxHashMap<CompKey, FanInAccum>,
) -> RenderFanInMetricParts {
let mut per_component = Vec::with_capacity(counts.len());
let mut distinct_parents_dist = Vec::with_capacity(counts.len());
let mut max_distinct_parents = 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,
});
}
per_component.sort_by(|a, b| {
a.file
.cmp(&b.file)
.then_with(|| a.component.cmp(&b.component))
});
RenderFanInMetricParts {
per_component,
distinct_parents_dist,
max_distinct_parents,
}
}
fn is_project_test_path(path: &Path, root: &Path) -> bool {
let rel = path.strip_prefix(root).unwrap_or(path);
super::predicates::is_test_or_spec_file(rel)
}
#[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")
)
}