use std::path::Path;
use rustc_hash::{FxHashMap, FxHashSet};
use fallow_types::extract::{ComponentFunction, ComponentProp, ModuleInfo, RenderEdge};
use crate::discover::FileId;
use crate::graph::ModuleGraph;
use crate::resolve::ResolvedModule;
use crate::results::{PropDrillHop, PropDrillingChain};
use super::react_resolve::{ChildResolver, CompKey};
use super::{LineOffsetsMap, byte_offset_to_line_col};
const MIN_CHAIN_DEPTH: usize = 3;
const MAX_CHAIN_DEPTH: usize = 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PropRole {
PassThrough,
Consumer,
}
struct CompState<'a> {
path: &'a Path,
span_start: u32,
abstain: bool,
prop_roles: FxHashMap<String, PropRole>,
prop_decls: FxHashMap<String, PropDecl>,
forwards: FxHashMap<String, Vec<ForwardTarget>>,
}
struct PropDecl {
name: String,
span_start: u32,
}
struct ForwardTarget {
child_name: String,
child_attr: String,
}
#[derive(Debug, Default)]
pub struct PropDrillingScan {
pub chains: Vec<PropDrillingChain>,
pub components_scanned: usize,
}
#[must_use]
pub fn find_prop_drilling_chains(
graph: &ModuleGraph,
modules: &[ModuleInfo],
resolved_modules: &[ResolvedModule],
declared_deps: &FxHashSet<String>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> PropDrillingScan {
let gated = declared_deps.contains("react")
|| declared_deps.contains("react-dom")
|| declared_deps.contains("next")
|| declared_deps.contains("preact");
if !gated {
return PropDrillingScan::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 mut states: FxHashMap<CompKey, CompState<'_>> = FxHashMap::default();
let mut components_scanned = 0usize;
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;
}
components_scanned += module.component_functions.len();
build_component_states(node.file_id, &node.path, module, &mut states);
}
let resolver = ChildResolver::new(graph, &modules_by_id, &resolved_by_id);
let chains = walk_chains(&states, &resolver, line_offsets_by_file);
PropDrillingScan {
chains,
components_scanned,
}
}
fn build_component_states<'a>(
file: FileId,
path: &'a Path,
module: &'a ModuleInfo,
states: &mut FxHashMap<CompKey, CompState<'a>>,
) {
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 mut props_by_comp: FxHashMap<&str, Vec<&ComponentProp>> = FxHashMap::default();
for prop in &module.react_props {
props_by_comp
.entry(prop.component.as_str())
.or_default()
.push(prop);
}
for func in &module.component_functions {
let name = func.name.as_str();
let edges = edges_by_parent.get(name).cloned().unwrap_or_default();
let component_abstain = component_has_abstain(func, &edges);
let mut prop_roles = FxHashMap::default();
let mut prop_decls = FxHashMap::default();
let mut forwards: FxHashMap<String, Vec<ForwardTarget>> = FxHashMap::default();
if let Some(props) = props_by_comp.get(name) {
for prop in props {
if !prop.used_in_script {
continue;
}
let role = if prop.used_outside_forward {
PropRole::Consumer
} else {
PropRole::PassThrough
};
prop_roles.insert(prop.local.clone(), role);
prop_decls.insert(
prop.local.clone(),
PropDecl {
name: prop.name.clone(),
span_start: prop.span_start,
},
);
}
}
for edge in &edges {
for fa in &edge.forward_attrs {
if prop_roles.contains_key(&fa.root) {
forwards
.entry(fa.root.clone())
.or_default()
.push(ForwardTarget {
child_name: edge.child_component_name.clone(),
child_attr: fa.attr.clone(),
});
}
}
}
states.insert(
CompKey {
file,
name: name.to_string(),
},
CompState {
path,
span_start: func.span_start,
abstain: component_abstain,
prop_roles,
prop_decls,
forwards,
},
);
}
}
fn component_has_abstain(func: &ComponentFunction, edges: &[&RenderEdge]) -> bool {
func.uses_clone_element
|| func.renders_provider
|| func.has_children_as_function
|| func.has_unharvestable_props
|| edges.iter().any(|e| e.has_spread || e.has_complex_forward)
}
fn non_maximal_origins(
states: &FxHashMap<CompKey, CompState<'_>>,
resolver: &ChildResolver<'_>,
) -> FxHashSet<(CompKey, String)> {
let mut non_maximal: FxHashSet<(CompKey, String)> = FxHashSet::default();
for (parent_key, parent_state) in states {
if parent_state.abstain {
continue;
}
for (parent_local, targets) in &parent_state.forwards {
if parent_state.prop_roles.get(parent_local) != Some(&PropRole::PassThrough) {
continue;
}
for target in targets {
let Some(child_key) = resolver.resolve(parent_key.file, &target.child_name) else {
continue;
};
let Some(child_state) = states.get(&child_key) else {
continue;
};
if let Some(child_local) = local_for_attr(child_state, &target.child_attr) {
non_maximal.insert((child_key, child_local));
}
}
}
}
non_maximal
}
fn walk_chains(
states: &FxHashMap<CompKey, CompState<'_>>,
resolver: &ChildResolver<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Vec<PropDrillingChain> {
let mut chains: Vec<PropDrillingChain> = Vec::new();
let mut seen: FxHashSet<(FileId, String, String)> = FxHashSet::default();
let non_maximal = non_maximal_origins(states, resolver);
for (key, state) in states {
if state.abstain {
continue;
}
for (local, role) in &state.prop_roles {
if *role != PropRole::PassThrough {
continue;
}
if non_maximal.contains(&(key.clone(), local.clone())) {
continue;
}
let Some(decl) = state.prop_decls.get(local) else {
continue;
};
let dedup_key = (key.file, key.name.clone(), decl.name.clone());
if seen.contains(&dedup_key) {
continue;
}
if let Some(hops) = follow_chain(key, local, states, resolver, line_offsets_by_file)
&& hops.len() >= MIN_CHAIN_DEPTH
{
seen.insert(dedup_key);
let depth = u32::try_from(hops.len()).unwrap_or(u32::MAX);
chains.push(PropDrillingChain {
prop: decl.name.clone(),
depth,
hops,
});
}
}
}
chains.sort_by(|a, b| {
let a_src = a.hops.first();
let b_src = b.hops.first();
a_src
.map(|h| &h.file)
.cmp(&b_src.map(|h| &h.file))
.then_with(|| a_src.map(|h| h.line).cmp(&b_src.map(|h| h.line)))
.then(a.prop.cmp(&b.prop))
});
chains
}
fn follow_chain(
origin: &CompKey,
origin_local: &str,
states: &FxHashMap<CompKey, CompState<'_>>,
resolver: &ChildResolver<'_>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> Option<Vec<PropDrillHop>> {
let mut hops: Vec<PropDrillHop> = Vec::new();
let mut visited: FxHashSet<CompKey> = FxHashSet::default();
let origin_state = states.get(origin)?;
let decl = origin_state.prop_decls.get(origin_local)?;
hops.push(hop_at(
origin,
origin_state.path,
decl.span_start,
line_offsets_by_file,
));
visited.insert(origin.clone());
let mut current_key = origin.clone();
let mut current_local = origin_local.to_string();
loop {
if hops.len() > MAX_CHAIN_DEPTH {
return Some(hops);
}
let state = states.get(¤t_key)?;
let targets = state.forwards.get(¤t_local)?;
let resolved: Vec<(CompKey, &ForwardTarget)> = targets
.iter()
.filter_map(|t| {
resolver
.resolve(current_key.file, &t.child_name)
.map(|k| (k, t))
})
.collect();
if resolved.len() != targets.len() {
return None;
}
let (child_key, target) = single_child(&resolved)?;
if !visited.insert(child_key.clone()) {
return None;
}
let child_state = states.get(&child_key)?;
if child_state.abstain {
return None;
}
let Some(child_local) = local_for_attr(child_state, &target.child_attr) else {
return None;
};
let role = *child_state.prop_roles.get(&child_local)?;
hops.push(hop_at(
&child_key,
child_state.path,
child_state.span_start,
line_offsets_by_file,
));
match role {
PropRole::Consumer => return Some(hops),
PropRole::PassThrough => {
current_key = child_key;
current_local = child_local;
}
}
}
}
fn single_child<'t>(
resolved: &'t [(CompKey, &'t ForwardTarget)],
) -> Option<(CompKey, &'t ForwardTarget)> {
let (first_key, first_target) = resolved.first()?;
if resolved
.iter()
.all(|(k, t)| k == first_key && t.child_attr == first_target.child_attr)
{
Some((first_key.clone(), first_target))
} else {
None
}
}
fn local_for_attr(state: &CompState<'_>, attr: &str) -> Option<String> {
state
.prop_decls
.iter()
.find(|(_, decl)| decl.name == attr)
.map(|(local, _)| local.clone())
}
fn hop_at(
key: &CompKey,
path: &Path,
span_start: u32,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> PropDrillHop {
let (line, _col) = byte_offset_to_line_col(line_offsets_by_file, key.file, span_start);
PropDrillHop {
file: path.to_path_buf(),
line,
component: key.name.clone(),
}
}
fn is_react_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("jsx" | "tsx")
)
}