use std::path::Path;
use rustc_hash::{FxHashMap, FxHashSet};
use fallow_types::extract::{
ComponentFunction, ComponentFunctionKind, FunctionComplexity, ModuleInfo, RenderEdge,
};
use crate::discover::FileId;
use crate::graph::ModuleGraph;
use crate::resolve::ResolvedModule;
use crate::results::ThinWrapper;
use super::react_resolve::ChildResolver;
use super::{LineOffsetsMap, byte_offset_to_line_col};
#[derive(Debug, Default)]
pub struct ThinWrapperScan {
pub wrappers: Vec<ThinWrapper>,
pub components_scanned: usize,
}
#[must_use]
pub fn find_thin_wrappers(
graph: &ModuleGraph,
modules: &[ModuleInfo],
resolved_modules: &[ResolvedModule],
declared_deps: &FxHashSet<String>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> ThinWrapperScan {
let gated = declared_deps.contains("react")
|| declared_deps.contains("react-dom")
|| declared_deps.contains("next")
|| declared_deps.contains("preact");
if !gated {
return ThinWrapperScan::default();
}
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 scan = ThinWrapperScan::default();
for node in &graph.modules {
if !node.is_reachable() || !is_react_file(&node.path) {
continue;
}
let Some(module) = modules_by_id.get(&node.file_id) else {
continue;
};
if module.component_functions.is_empty() {
continue;
}
scan.components_scanned += module.component_functions.len();
let mut edges_by_parent: FxHashMap<&str, Vec<&RenderEdge>> = FxHashMap::default();
for edge in &module.render_edges {
edges_by_parent
.entry(edge.parent_component.as_str())
.or_default()
.push(edge);
}
let complexity_by_name: FxHashMap<&str, &FunctionComplexity> = module
.complexity
.iter()
.map(|c| (c.name.as_str(), c))
.collect();
let hook_counts = component_hook_counts(module);
let ctx = FileContext {
file: node.file_id,
path: &node.path,
edges_by_parent: &edges_by_parent,
complexity_by_name: &complexity_by_name,
hook_counts: &hook_counts,
};
for func in &module.component_functions {
if let Some(wrapper) =
classify_thin_wrapper(func, &ctx, &resolver, line_offsets_by_file)
{
scan.wrappers.push(wrapper);
}
}
}
scan.wrappers.sort_by(|a, b| {
a.file
.cmp(&b.file)
.then(a.line.cmp(&b.line))
.then(a.component.cmp(&b.component))
});
scan
}
fn component_hook_counts(module: &ModuleInfo) -> FxHashMap<u32, usize> {
let mut counts: FxHashMap<u32, usize> = FxHashMap::default();
if module.hook_uses.is_empty() {
return counts;
}
let mut starts: Vec<u32> = module
.component_functions
.iter()
.map(|c| c.span_start)
.collect();
starts.sort_unstable();
for hook in &module.hook_uses {
let owner = starts
.partition_point(|&s| s <= hook.span_start)
.checked_sub(1)
.map(|idx| starts[idx]);
if let Some(owner) = owner {
*counts.entry(owner).or_insert(0) += 1;
}
}
counts
}
struct FileContext<'a> {
file: FileId,
path: &'a Path,
edges_by_parent: &'a FxHashMap<&'a str, Vec<&'a RenderEdge>>,
complexity_by_name: &'a FxHashMap<&'a str, &'a FunctionComplexity>,
hook_counts: &'a FxHashMap<u32, usize>,
}
fn classify_thin_wrapper(
func: &ComponentFunction,
ctx: &FileContext<'_>,
resolver: &ChildResolver<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Option<ThinWrapper> {
if !func.is_pure_passthrough {
return None;
}
if matches!(
func.kind,
ComponentFunctionKind::ForwardRefWrapper | ComponentFunctionKind::MemoWrapper
) {
return None;
}
if func.is_exported {
return None;
}
if func.renders_provider {
return None;
}
if func.uses_clone_element || func.has_children_as_function {
return None;
}
let name = func.name.as_str();
if ctx.hook_counts.get(&func.span_start).copied().unwrap_or(0) != 0 {
tracing::debug!(
component = name,
"thin-wrapper: is_pure_passthrough set but the component owns a hook; abstaining"
);
return None;
}
if let Some(complexity) = ctx.complexity_by_name.get(name)
&& (complexity.cyclomatic != 1 || complexity.react_hook_count != 0)
{
tracing::debug!(
component = name,
cyclomatic = complexity.cyclomatic,
react_hook_count = complexity.react_hook_count,
"thin-wrapper: is_pure_passthrough disagrees with complexity join; abstaining"
);
return None;
}
let edges = ctx.edges_by_parent.get(name)?;
let [edge] = edges.as_slice() else {
return None;
};
let child_name = edge.child_component_name.as_str();
if child_name.contains('.') {
return None;
}
if child_name == name {
return None;
}
let resolved = resolver.resolve(ctx.file, child_name);
if resolved.is_none() && !resolver.is_imported_binding(ctx.file, child_name) {
return None;
}
if let Some(target) = resolved
&& target.file == ctx.file
&& target.name == name
{
return None;
}
let (line, _col) = byte_offset_to_line_col(line_offsets_by_file, ctx.file, func.span_start);
Some(ThinWrapper {
file: ctx.path.to_path_buf(),
line,
component: name.to_string(),
child_component: child_name.to_string(),
})
}
fn is_react_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("jsx" | "tsx")
)
}