use std::path::{Path, PathBuf};
use rustc_hash::{FxHashMap, FxHashSet};
use fallow_types::extract::ModuleInfo;
use crate::discover::FileId;
use crate::graph::ModuleGraph;
use crate::results::{DuplicatePropShape, DuplicatePropShapeMember};
use super::{LineOffsetsMap, byte_offset_to_line_col};
const MIN_SIGNIFICANT_PROPS: usize = 4;
const MIN_GROUP_SIZE: usize = 3;
const MIN_DISTINCT_FILES: usize = 2;
#[derive(Debug, Default)]
pub struct DuplicatePropShapeScan {
pub groups: Vec<DuplicatePropShape>,
pub components_scanned: usize,
}
struct Member {
file: FileId,
span_start: u32,
component_name: String,
path: PathBuf,
}
#[must_use]
pub fn find_duplicate_prop_shapes(
graph: &ModuleGraph,
modules: &[ModuleInfo],
declared_deps: &FxHashSet<String>,
line_offsets_by_file: &LineOffsetsMap<'_>,
) -> DuplicatePropShapeScan {
let gated = declared_deps.contains("react")
|| declared_deps.contains("react-dom")
|| declared_deps.contains("next")
|| declared_deps.contains("preact");
if !gated {
return DuplicatePropShapeScan::default();
}
let modules_by_id: FxHashMap<FileId, &ModuleInfo> =
modules.iter().map(|m| (m.file_id, m)).collect();
let mut buckets: FxHashMap<Vec<String>, Vec<Member>> = 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();
let mut props_by_comp: FxHashMap<&str, Vec<&str>> = FxHashMap::default();
for prop in &module.react_props {
props_by_comp
.entry(prop.component.as_str())
.or_default()
.push(prop.name.as_str());
}
for func in &module.component_functions {
if func.has_unharvestable_props {
continue;
}
let Some(names) = props_by_comp.get(func.name.as_str()) else {
continue;
};
let significant = significant_prop_set(names);
if significant.len() < MIN_SIGNIFICANT_PROPS {
continue;
}
buckets.entry(significant).or_default().push(Member {
file: node.file_id,
span_start: func.span_start,
component_name: func.name.clone(),
path: node.path.clone(),
});
}
}
let mut groups = Vec::new();
for (shape, mut members) in buckets {
if members.len() < MIN_GROUP_SIZE {
continue;
}
let distinct_files: FxHashSet<FileId> = members.iter().map(|m| m.file).collect();
if distinct_files.len() < MIN_DISTINCT_FILES {
continue;
}
members.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.span_start.cmp(&b.span_start))
.then(a.component_name.cmp(&b.component_name))
});
emit_group(&shape, &members, line_offsets_by_file, &mut groups);
}
groups.sort_by(|a, b| {
a.shape
.cmp(&b.shape)
.then(a.file.cmp(&b.file))
.then(a.line.cmp(&b.line))
.then(a.component.cmp(&b.component))
});
DuplicatePropShapeScan {
groups,
components_scanned,
}
}
fn emit_group(
shape: &[String],
members: &[Member],
line_offsets_by_file: &LineOffsetsMap<'_>,
out: &mut Vec<DuplicatePropShape>,
) {
let group_size = u32::try_from(members.len()).unwrap_or(u32::MAX);
for member in members {
let (line, _col) =
byte_offset_to_line_col(line_offsets_by_file, member.file, member.span_start);
let sharing_components: Vec<DuplicatePropShapeMember> = members
.iter()
.filter(|other| {
other.file != member.file || other.component_name != member.component_name
})
.map(|other| {
let (other_line, _) =
byte_offset_to_line_col(line_offsets_by_file, other.file, other.span_start);
DuplicatePropShapeMember {
file: other.path.clone(),
line: other_line,
component: other.component_name.clone(),
}
})
.collect();
out.push(DuplicatePropShape {
file: member.path.clone(),
line,
component: member.component_name.clone(),
shape: shape.to_vec(),
group_size,
sharing_components,
});
}
}
fn significant_prop_set(names: &[&str]) -> Vec<String> {
let mut significant: Vec<String> = names
.iter()
.filter(|name| !is_ubiquitous_prop(name))
.map(|name| (*name).to_string())
.collect();
significant.sort_unstable();
significant.dedup();
significant
}
fn is_ubiquitous_prop(name: &str) -> bool {
if name.starts_with("data-") || name.starts_with("aria-") {
return true;
}
UBIQUITOUS_PROP_NAMES.contains(&name)
}
const UBIQUITOUS_PROP_NAMES: &[&str] = &[
"autoFocus",
"children",
"class",
"className",
"contentEditable",
"dir",
"disabled",
"draggable",
"hidden",
"id",
"key",
"lang",
"name",
"onBlur",
"onChange",
"onClick",
"onFocus",
"onInput",
"onKeyDown",
"onMouseDown",
"onMouseEnter",
"onMouseLeave",
"onMouseUp",
"onPointerDown",
"onPointerUp",
"onScroll",
"onSubmit",
"ref",
"role",
"slot",
"style",
"tabIndex",
"title",
];
fn is_react_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("jsx" | "tsx")
)
}