react_auditor/rules/performance/
prefer_fragments.rs1use oxc_ast::ast::Program;
2use oxc_ast_visit::Visit;
3use oxc_semantic::Semantic;
4
5use crate::rules::{Fix, Rule, RuleFinding, RuleMeta, Severity};
6
7pub struct PreferFragments;
8
9const RULE_META: RuleMeta = RuleMeta {
10 id: "prefer-fragments",
11 default_severity: Severity::Warning,
12 category: "performance",
13 description: "Use `<></>` over unnecessary wrapper divs",
14};
15
16impl Rule for PreferFragments {
17 fn meta(&self) -> &RuleMeta {
18 &RULE_META
19 }
20
21 fn run(&self, program: &Program, _semantic: &Semantic, source_text: &str) -> Vec<RuleFinding> {
22 let mut collector = FragmentCollector {
23 findings: Vec::new(),
24 source: source_text,
25 };
26 collector.visit_program(program);
27 collector.findings
28 }
29
30 fn has_fix(&self) -> bool {
31 true
32 }
33
34 fn fix(&self, finding: &RuleFinding, source_text: &str) -> Option<Fix> {
35 let offset = crate::rules::line_col_to_offset(source_text, finding.line, finding.column)?;
36 let after = &source_text[offset..];
37 if !after.starts_with("<div") {
38 return None;
39 }
40 let opening_close = after.find('>')?;
41 let content_start = offset + opening_close + 1;
42
43 let closing_tag_start = find_matching_closing_div(source_text, content_start)?;
44 let closing_tag_end = closing_tag_start + 6;
45
46 let inner = &source_text[content_start..closing_tag_start];
47
48 Some(Fix {
49 start: offset,
50 end: closing_tag_end,
51 replacement: format!("<>{inner}</>"),
52 })
53 }
54}
55
56fn find_matching_closing_div(source: &str, start: usize) -> Option<usize> {
57 let bytes = source.as_bytes();
58 let mut depth = 1u32;
59 let mut i = start;
60 while i + 5 < bytes.len() {
61 if bytes[i] == b'<'
62 && bytes[i + 1] == b'/'
63 && bytes[i + 2] == b'd'
64 && bytes[i + 3] == b'i'
65 && bytes[i + 4] == b'v'
66 && bytes[i + 5] == b'>'
67 {
68 depth -= 1;
69 if depth == 0 {
70 return Some(i);
71 }
72 i += 6;
73 } else if bytes[i] == b'<'
74 && bytes[i + 1] != b'/'
75 && i + 4 < bytes.len()
76 && bytes[i + 1] == b'd'
77 && bytes[i + 2] == b'i'
78 && bytes[i + 3] == b'v'
79 && (bytes[i + 4] == b'>'
80 || bytes[i + 4] == b' '
81 || bytes[i + 4] == b'\t'
82 || bytes[i + 4] == b'\n')
83 {
84 depth += 1;
85 let tag_close = source[i..].find('>')?;
86 i += tag_close + 1;
87 } else {
88 i += 1;
89 }
90 }
91 None
92}
93
94struct FragmentCollector<'a> {
95 findings: Vec<RuleFinding>,
96 source: &'a str,
97}
98
99impl<'a> Visit<'a> for FragmentCollector<'a> {
100 fn visit_jsx_opening_element(&mut self, el: &oxc_ast::ast::JSXOpeningElement<'a>) {
101 let is_div = matches!(&el.name, oxc_ast::ast::JSXElementName::Identifier(id) if id.name.as_str() == "div");
102 if !is_div {
103 return;
104 }
105 let has_class = el.attributes.iter().any(|attr| {
106 if let oxc_ast::ast::JSXAttributeItem::Attribute(a) = attr {
107 matches!(&a.name, oxc_ast::ast::JSXAttributeName::Identifier(id) if id.name.as_str() == "className" || id.name.as_str() == "class")
108 } else {
109 false
110 }
111 });
112 if has_class {
113 return;
114 }
115 let has_style = el.attributes.iter().any(|attr| {
116 if let oxc_ast::ast::JSXAttributeItem::Attribute(a) = attr {
117 matches!(&a.name, oxc_ast::ast::JSXAttributeName::Identifier(id) if id.name.as_str() == "style")
118 } else {
119 false
120 }
121 });
122 if !has_class && !has_style {
123 let start = el.span.start as usize;
124 let line = self.source[..start].lines().count().max(1);
125 let col = start - self.source[..start].rfind('\n').map(|i| i + 1).unwrap_or(0);
126 self.findings.push(RuleFinding {
127 line,
128 column: col + 1,
129 message: "Unnecessary `<div>` wrapper — use `<></>` Fragment instead".to_string(),
130 });
131 }
132 }
133}