use std::path::Path;
use oxc_ast::ast::{
ArrowFunctionExpression, BindingPatternKind, CallExpression, Declaration, Expression, Function,
FunctionBody, Program, Statement, VariableDeclarator,
};
use oxc_ast_visit::{walk, Visit};
use oxc_span::Span;
use crate::{
rules::{Issue, RuleContext, Severity},
utils::offset_to_line_col,
};
pub struct LargeComponent;
impl super::Rule for LargeComponent {
fn name(&self) -> &str {
"large_component"
}
fn run(&self, ctx: &RuleContext<'_>) -> Vec<Issue> {
find_large_components(
ctx.program,
ctx.source_text,
ctx.file_path,
ctx.max_component_lines,
)
}
}
#[derive(Debug, Default)]
struct LineMetrics {
total: usize,
blank: usize,
comment: usize,
}
impl LineMetrics {
fn logical(&self) -> usize {
self.total.saturating_sub(self.blank + self.comment)
}
}
fn measure_lines(source: &str, start: u32, end: u32) -> LineMetrics {
let start = (start as usize).min(source.len());
let end = (end as usize).min(source.len());
if start >= end {
return LineMetrics::default();
}
let mut m = LineMetrics::default();
for raw_line in source[start..end].lines() {
m.total += 1;
let trimmed = raw_line.trim();
if trimmed.is_empty() {
m.blank += 1;
} else if trimmed.starts_with("//") || trimmed.starts_with("/*") || trimmed.starts_with('*')
{
m.comment += 1;
}
}
m
}
struct ComplexityMetrics {
jsx_elements: usize,
hook_calls: usize,
}
fn measure_complexity(body: &FunctionBody<'_>) -> ComplexityMetrics {
let mut v = ComplexityVisitor {
jsx_elements: 0,
hook_calls: 0,
};
v.visit_function_body(body);
ComplexityMetrics {
jsx_elements: v.jsx_elements,
hook_calls: v.hook_calls,
}
}
fn measure_complexity_arrow(arrow: &ArrowFunctionExpression<'_>) -> ComplexityMetrics {
let mut v = ComplexityVisitor {
jsx_elements: 0,
hook_calls: 0,
};
v.visit_arrow_function_expression(arrow);
ComplexityMetrics {
jsx_elements: v.jsx_elements,
hook_calls: v.hook_calls,
}
}
struct ComplexityVisitor {
jsx_elements: usize,
hook_calls: usize,
}
impl<'a> Visit<'a> for ComplexityVisitor {
fn visit_jsx_element(&mut self, elem: &oxc_ast::ast::JSXElement<'a>) {
self.jsx_elements += 1;
walk::walk_jsx_element(self, elem);
}
fn visit_jsx_fragment(&mut self, frag: &oxc_ast::ast::JSXFragment<'a>) {
self.jsx_elements += 1;
walk::walk_jsx_fragment(self, frag);
}
fn visit_call_expression(&mut self, call: &oxc_ast::ast::CallExpression<'a>) {
if is_hook_call(&call.callee) {
self.hook_calls += 1;
}
walk::walk_call_expression(self, call);
}
}
fn is_hook_call(callee: &Expression<'_>) -> bool {
match callee {
Expression::Identifier(id) => looks_like_hook(id.name.as_str()),
Expression::StaticMemberExpression(m) => looks_like_hook(m.property.name.as_str()),
_ => false,
}
}
#[inline]
fn looks_like_hook(name: &str) -> bool {
name.starts_with("use")
&& name.len() > 3
&& name
.chars()
.nth(3)
.map(|c| c.is_uppercase())
.unwrap_or(false)
}
const HOC_WRAPPERS: &[&str] = &["memo", "forwardRef"];
fn unwrap_hoc<'a>(call: &'a CallExpression<'a>) -> Option<&'a Expression<'a>> {
if !is_hoc_callee(&call.callee) {
return None;
}
let first_arg = call.arguments.first()?;
let inner = first_arg.as_expression()?;
if matches!(
inner,
Expression::ArrowFunctionExpression(_) | Expression::FunctionExpression(_)
) {
Some(inner)
} else {
None
}
}
fn is_hoc_callee(callee: &Expression<'_>) -> bool {
match callee {
Expression::Identifier(id) => HOC_WRAPPERS.contains(&id.name.as_str()),
Expression::StaticMemberExpression(m) => HOC_WRAPPERS.contains(&m.property.name.as_str()),
_ => false,
}
}
fn find_large_components(
program: &Program<'_>,
source_text: &str,
file_path: &Path,
max_lines: usize,
) -> Vec<Issue> {
let mut issues = Vec::new();
for stmt in &program.body {
match stmt {
Statement::FunctionDeclaration(func) => {
check_function(func, source_text, file_path, max_lines, &mut issues);
}
Statement::VariableDeclaration(var_decl) => {
for decl in &var_decl.declarations {
check_variable_declarator(decl, source_text, file_path, max_lines, &mut issues);
}
}
Statement::ExportDefaultDeclaration(export) => {
use oxc_ast::ast::ExportDefaultDeclarationKind;
match &export.declaration {
ExportDefaultDeclarationKind::FunctionDeclaration(func) => {
check_function(func, source_text, file_path, max_lines, &mut issues);
}
ExportDefaultDeclarationKind::ArrowFunctionExpression(arrow) => {
let name = extract_filename(file_path);
if arrow_contains_jsx(arrow) {
report_if_large_arrow(
arrow,
&name,
source_text,
file_path,
max_lines,
&mut issues,
);
}
}
_ => {}
}
}
Statement::ExportNamedDeclaration(export) => {
if let Some(decl) = &export.declaration {
match decl {
Declaration::FunctionDeclaration(func) => {
check_function(func, source_text, file_path, max_lines, &mut issues);
}
Declaration::VariableDeclaration(var_decl) => {
for d in &var_decl.declarations {
check_variable_declarator(
d,
source_text,
file_path,
max_lines,
&mut issues,
);
}
}
_ => {}
}
}
}
_ => {}
}
}
issues
}
fn check_function(
func: &Function<'_>,
source_text: &str,
file_path: &Path,
max_lines: usize,
issues: &mut Vec<Issue>,
) {
let name = match &func.id {
Some(id) => id.name.as_str(),
None => return, };
if !is_pascal_case(name) {
return;
}
if let Some(body) = &func.body {
if !body_contains_jsx(body) {
return;
}
let metrics = measure_lines(source_text, func.span.start, func.span.end);
if metrics.total > max_lines {
let complexity = measure_complexity(body);
report(
func.span,
name,
&metrics,
&complexity,
source_text,
file_path,
max_lines,
issues,
);
}
}
}
fn check_variable_declarator(
decl: &VariableDeclarator<'_>,
source_text: &str,
file_path: &Path,
max_lines: usize,
issues: &mut Vec<Issue>,
) {
let name = match &decl.id.kind {
BindingPatternKind::BindingIdentifier(id) => id.name.as_str(),
_ => return,
};
if !is_pascal_case(name) {
return;
}
let Some(init) = &decl.init else { return };
let effective_fn: &Expression<'_> = match init {
Expression::CallExpression(call) => match unwrap_hoc(call) {
Some(inner) => inner,
None => return,
},
other => other,
};
match effective_fn {
Expression::ArrowFunctionExpression(arrow) => {
if arrow_contains_jsx(arrow) {
report_if_large_arrow(arrow, name, source_text, file_path, max_lines, issues);
}
}
Expression::FunctionExpression(func) => {
if let Some(body) = &func.body {
if body_contains_jsx(body) {
let metrics = measure_lines(source_text, func.span.start, func.span.end);
if metrics.total > max_lines {
let complexity = measure_complexity(body);
report(
func.span,
name,
&metrics,
&complexity,
source_text,
file_path,
max_lines,
issues,
);
}
}
}
}
_ => {}
}
}
fn report_if_large_arrow(
arrow: &ArrowFunctionExpression<'_>,
name: &str,
source_text: &str,
file_path: &Path,
max_lines: usize,
issues: &mut Vec<Issue>,
) {
let metrics = measure_lines(source_text, arrow.span.start, arrow.span.end);
if metrics.total > max_lines {
let complexity = measure_complexity_arrow(arrow);
report(
arrow.span,
name,
&metrics,
&complexity,
source_text,
file_path,
max_lines,
issues,
);
}
}
#[allow(clippy::too_many_arguments)]
fn report(
span: Span,
name: &str,
lines: &LineMetrics,
complexity: &ComplexityMetrics,
source_text: &str,
file_path: &Path,
max_lines: usize,
issues: &mut Vec<Issue>,
) {
let (line, col) = offset_to_line_col(source_text, span.start);
let suggestion = build_split_suggestion(lines, complexity);
issues.push(Issue {
rule: "large_component".to_string(),
message: format!(
"Component '{name}' is {total} lines ({logical} logical, {blank} blank) — \
limit is {max_lines}. \
Render complexity: {jsx} JSX elements, {hooks} hooks. \
{suggestion}",
total = lines.total,
logical = lines.logical(),
blank = lines.blank,
jsx = complexity.jsx_elements,
hooks = complexity.hook_calls,
),
file: file_path.to_path_buf(),
line,
column: col,
severity: Severity::Medium,
source: crate::rules::IssueSource::ReactPerfAnalyzer,
category: crate::rules::IssueCategory::Performance,
});
}
fn build_split_suggestion(lines: &LineMetrics, cx: &ComplexityMetrics) -> String {
let logical = lines.logical();
if cx.hook_calls >= 5 && cx.jsx_elements >= 10 {
format!(
"With {} hooks and {} JSX elements, this component has too many responsibilities. \
Extract each major UI section into its own sub-component, and consider a \
custom hook to consolidate related state.",
cx.hook_calls, cx.jsx_elements
)
} else if cx.hook_calls >= 5 {
format!(
"{} hook calls suggest complex state logic. Extract into a custom hook \
(e.g. `use{}State`) to separate concerns.",
cx.hook_calls,
"Component"
)
} else if cx.jsx_elements >= 10 {
format!(
"{} JSX elements make this hard to memoize selectively. \
Split the render into 2-3 focused sub-components, each wrapped \
in `React.memo` for fine-grained update control.",
cx.jsx_elements
)
} else if logical > 200 {
"Consider splitting this component at its natural seams \
(tabs, sections, or feature boundaries)."
.to_string()
} else {
"Consider extracting sub-components or custom hooks to reduce size.".to_string()
}
}
fn body_contains_jsx(body: &FunctionBody<'_>) -> bool {
let mut checker = JsxPresenceChecker { found: false };
checker.visit_function_body(body);
checker.found
}
fn arrow_contains_jsx(arrow: &ArrowFunctionExpression<'_>) -> bool {
let mut checker = JsxPresenceChecker { found: false };
checker.visit_arrow_function_expression(arrow);
checker.found
}
struct JsxPresenceChecker {
found: bool,
}
impl<'a> Visit<'a> for JsxPresenceChecker {
fn visit_jsx_element(&mut self, elem: &oxc_ast::ast::JSXElement<'a>) {
self.found = true;
let _ = elem; }
fn visit_jsx_fragment(&mut self, frag: &oxc_ast::ast::JSXFragment<'a>) {
self.found = true;
let _ = frag;
}
fn visit_expression(&mut self, expr: &Expression<'a>) {
if !self.found {
walk::walk_expression(self, expr);
}
}
}
#[inline]
fn is_pascal_case(name: &str) -> bool {
name.chars()
.next()
.map(|c| c.is_ascii_uppercase())
.unwrap_or(false)
}
fn extract_filename(path: &Path) -> String {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("DefaultExport")
.to_string()
}