1use crate::config::{RuleConfig, Severity};
2use crate::rules::ast::{count_calls_in_scope, is_component_node, parse_file};
3use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
4
5pub struct PreferUseReducerRule {
11 id: String,
12 severity: Severity,
13 message: String,
14 suggest: Option<String>,
15 glob: Option<String>,
16 max_count: usize,
17}
18
19impl PreferUseReducerRule {
20 pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
21 Ok(Self {
22 id: config.id.clone(),
23 severity: config.severity,
24 message: config.message.clone(),
25 suggest: config.suggest.clone(),
26 glob: config.glob.clone(),
27 max_count: config.max_count.unwrap_or(4),
28 })
29 }
30}
31
32impl Rule for PreferUseReducerRule {
33 fn id(&self) -> &str {
34 &self.id
35 }
36
37 fn severity(&self) -> Severity {
38 self.severity
39 }
40
41 fn file_glob(&self) -> Option<&str> {
42 self.glob.as_deref()
43 }
44
45 fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
46 let mut violations = Vec::new();
47 let tree = match parse_file(ctx.file_path, ctx.content) {
48 Some(t) => t,
49 None => return violations,
50 };
51 let source = ctx.content.as_bytes();
52 self.visit(tree.root_node(), source, ctx, &mut violations);
53 violations
54 }
55}
56
57impl PreferUseReducerRule {
58 fn visit(
59 &self,
60 node: tree_sitter::Node,
61 source: &[u8],
62 ctx: &ScanContext,
63 violations: &mut Vec<Violation>,
64 ) {
65 if is_component_node(&node, source) {
66 let count = count_calls_in_scope(node, source, "useState");
67 if count >= self.max_count {
68 let line = node.start_position().row;
69 violations.push(Violation {
70 rule_id: self.id.clone(),
71 severity: self.severity,
72 file: ctx.file_path.to_path_buf(),
73 line: Some(line + 1),
74 column: Some(1),
75 message: self.message.clone(),
76 suggest: self.suggest.clone(),
77 source_line: ctx.content.lines().nth(line).map(String::from),
78 fix: None,
79 });
80 }
81 }
82
83 for i in 0..node.child_count() {
84 if let Some(child) = node.child(i) {
85 self.visit(child, source, ctx, violations);
86 }
87 }
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use std::path::Path;
95
96 fn make_config(max_count: usize) -> RuleConfig {
97 RuleConfig {
98 id: "prefer-use-reducer".into(),
99 severity: Severity::Warning,
100 message: format!("Component has {}+ useState calls", max_count),
101 suggest: Some("Consider useReducer for related state".into()),
102 glob: Some("**/*.tsx".into()),
103 max_count: Some(max_count),
104 ..Default::default()
105 }
106 }
107
108 fn check(rule: &PreferUseReducerRule, content: &str) -> Vec<Violation> {
109 let ctx = ScanContext {
110 file_path: Path::new("test.tsx"),
111 content,
112 };
113 rule.check_file(&ctx)
114 }
115
116 #[test]
117 fn under_threshold_no_violation() {
118 let rule = PreferUseReducerRule::new(&make_config(4)).unwrap();
119 let content = "\
120function MyComponent() {
121 const [a, setA] = useState(0);
122 const [b, setB] = useState('');
123 const [c, setC] = useState(false);
124 return <div>{a}{b}{c}</div>;
125}";
126 let violations = check(&rule, content);
127 assert!(violations.is_empty());
128 }
129
130 #[test]
131 fn at_threshold_triggers() {
132 let rule = PreferUseReducerRule::new(&make_config(4)).unwrap();
133 let content = "\
134function MyComponent() {
135 const [a, setA] = useState(0);
136 const [b, setB] = useState('');
137 const [c, setC] = useState(false);
138 const [d, setD] = useState(null);
139 return <div />;
140}";
141 let violations = check(&rule, content);
142 assert_eq!(violations.len(), 1);
143 assert_eq!(violations[0].line, Some(1));
144 }
145
146 #[test]
147 fn over_threshold_triggers() {
148 let rule = PreferUseReducerRule::new(&make_config(3)).unwrap();
149 let content = "\
150function Form() {
151 const [name, setName] = useState('');
152 const [email, setEmail] = useState('');
153 const [phone, setPhone] = useState('');
154 const [address, setAddress] = useState('');
155 return <form />;
156}";
157 let violations = check(&rule, content);
158 assert_eq!(violations.len(), 1);
159 }
160
161 #[test]
162 fn arrow_function_component() {
163 let rule = PreferUseReducerRule::new(&make_config(2)).unwrap();
164 let content = "\
165const MyComponent = () => {
166 const [a, setA] = useState(0);
167 const [b, setB] = useState(0);
168 return <div />;
169};";
170 let violations = check(&rule, content);
171 assert_eq!(violations.len(), 1);
172 }
173
174 #[test]
175 fn nested_component_counted_separately() {
176 let rule = PreferUseReducerRule::new(&make_config(3)).unwrap();
177 let content = "\
179function Outer() {
180 const [a, setA] = useState(0);
181 const [b, setB] = useState(0);
182 function Inner() {
183 const [c, setC] = useState(0);
184 const [d, setD] = useState(0);
185 return <span />;
186 }
187 return <Inner />;
188}";
189 let violations = check(&rule, content);
190 assert!(violations.is_empty());
191 }
192
193 #[test]
194 fn lowercase_function_ignored() {
195 let rule = PreferUseReducerRule::new(&make_config(2)).unwrap();
196 let content = "\
197function useMyHook() {
198 const [a, setA] = useState(0);
199 const [b, setB] = useState(0);
200 const [c, setC] = useState(0);
201 return [a, b, c];
202}";
203 let violations = check(&rule, content);
204 assert!(violations.is_empty());
205 }
206
207 #[test]
208 fn default_max_count() {
209 let config = RuleConfig {
210 id: "test".into(),
211 severity: Severity::Warning,
212 message: "test".into(),
213 ..Default::default()
214 };
215 let rule = PreferUseReducerRule::new(&config).unwrap();
216 assert_eq!(rule.max_count, 4);
217 }
218
219 #[test]
220 fn non_tsx_file_skipped() {
221 let rule = PreferUseReducerRule::new(&make_config(2)).unwrap();
222 let ctx = ScanContext {
223 file_path: Path::new("test.rs"),
224 content: "fn main() {}",
225 };
226 assert!(rule.check_file(&ctx).is_empty());
227 }
228}