code_baseline/rules/ast/
no_cascading_set_state.rs1use crate::config::{RuleConfig, Severity};
2use crate::rules::ast::parse_file;
3use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
4
5pub struct NoCascadingSetStateRule {
12 id: String,
13 severity: Severity,
14 message: String,
15 suggest: Option<String>,
16 glob: Option<String>,
17 max_count: usize,
18}
19
20impl NoCascadingSetStateRule {
21 pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
22 Ok(Self {
23 id: config.id.clone(),
24 severity: config.severity,
25 message: config.message.clone(),
26 suggest: config.suggest.clone(),
27 glob: config.glob.clone(),
28 max_count: config.max_count.unwrap_or(3),
29 })
30 }
31}
32
33impl Rule for NoCascadingSetStateRule {
34 fn id(&self) -> &str {
35 &self.id
36 }
37
38 fn severity(&self) -> Severity {
39 self.severity
40 }
41
42 fn file_glob(&self) -> Option<&str> {
43 self.glob.as_deref()
44 }
45
46 fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
47 let mut violations = Vec::new();
48 let tree = match parse_file(ctx.file_path, ctx.content) {
49 Some(t) => t,
50 None => return violations,
51 };
52 let source = ctx.content.as_bytes();
53 self.visit(tree.root_node(), source, ctx, &mut violations);
54 violations
55 }
56}
57
58impl NoCascadingSetStateRule {
59 fn visit(
60 &self,
61 node: tree_sitter::Node,
62 source: &[u8],
63 ctx: &ScanContext,
64 violations: &mut Vec<Violation>,
65 ) {
66 if node.kind() == "call_expression" {
68 if let Some(func) = node.child_by_field_name("function") {
69 if func.kind() == "identifier" {
70 if let Ok(name) = func.utf8_text(source) {
71 if name == "useEffect" {
72 if let Some(args) = node.child_by_field_name("arguments") {
73 if let Some(callback) = args.named_child(0) {
74 let count = count_set_state_calls(callback, source);
75 if count >= self.max_count {
76 let line = node.start_position().row;
77 violations.push(Violation {
78 rule_id: self.id.clone(),
79 severity: self.severity,
80 file: ctx.file_path.to_path_buf(),
81 line: Some(line + 1),
82 column: Some(node.start_position().column + 1),
83 message: self.message.clone(),
84 suggest: self.suggest.clone(),
85 source_line: ctx
86 .content
87 .lines()
88 .nth(line)
89 .map(String::from),
90 fix: None,
91 });
92 }
93 }
94 }
95 }
96 }
97 }
98 }
99 }
100
101 for i in 0..node.child_count() {
102 if let Some(child) = node.child(i) {
103 self.visit(child, source, ctx, violations);
104 }
105 }
106 }
107}
108
109fn count_set_state_calls(node: tree_sitter::Node, source: &[u8]) -> usize {
111 let mut count = 0;
112
113 if node.kind() == "call_expression" {
114 if let Some(func) = node.child_by_field_name("function") {
115 if func.kind() == "identifier" {
116 if let Ok(name) = func.utf8_text(source) {
117 if is_set_state_name(name) {
118 count += 1;
119 }
120 }
121 }
122 }
123 }
124
125 for i in 0..node.child_count() {
126 if let Some(child) = node.child(i) {
127 count += count_set_state_calls(child, source);
128 }
129 }
130
131 count
132}
133
134fn is_set_state_name(name: &str) -> bool {
136 if let Some(rest) = name.strip_prefix("set") {
137 rest.starts_with(|c: char| c.is_ascii_uppercase())
138 } else {
139 false
140 }
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use std::path::Path;
147
148 fn make_config(max_count: usize) -> RuleConfig {
149 RuleConfig {
150 id: "no-cascading-set-state".into(),
151 severity: Severity::Warning,
152 message: format!("useEffect has {}+ setState calls", max_count),
153 suggest: Some("Consider useReducer".into()),
154 glob: Some("**/*.tsx".into()),
155 max_count: Some(max_count),
156 ..Default::default()
157 }
158 }
159
160 fn check(rule: &NoCascadingSetStateRule, content: &str) -> Vec<Violation> {
161 let ctx = ScanContext {
162 file_path: Path::new("test.tsx"),
163 content,
164 };
165 rule.check_file(&ctx)
166 }
167
168 #[test]
169 fn under_threshold_no_violation() {
170 let rule = NoCascadingSetStateRule::new(&make_config(3)).unwrap();
171 let content = "\
172function MyComponent() {
173 const [a, setA] = useState(0);
174 const [b, setB] = useState(0);
175 useEffect(() => {
176 setA(1);
177 setB(2);
178 }, []);
179 return <div />;
180}";
181 let violations = check(&rule, content);
182 assert!(violations.is_empty());
183 }
184
185 #[test]
186 fn at_threshold_triggers() {
187 let rule = NoCascadingSetStateRule::new(&make_config(3)).unwrap();
188 let content = "\
189function MyComponent() {
190 const [a, setA] = useState(0);
191 const [b, setB] = useState(0);
192 const [c, setC] = useState(0);
193 useEffect(() => {
194 setA(1);
195 setB(2);
196 setC(3);
197 }, []);
198 return <div />;
199}";
200 let violations = check(&rule, content);
201 assert_eq!(violations.len(), 1);
202 }
203
204 #[test]
205 fn over_threshold_triggers() {
206 let rule = NoCascadingSetStateRule::new(&make_config(2)).unwrap();
207 let content = "\
208function MyComponent() {
209 useEffect(() => {
210 setName('test');
211 setEmail('test@test.com');
212 setPhone('123');
213 }, []);
214 return <div />;
215}";
216 let violations = check(&rule, content);
217 assert_eq!(violations.len(), 1);
218 }
219
220 #[test]
221 fn separate_effects_counted_independently() {
222 let rule = NoCascadingSetStateRule::new(&make_config(3)).unwrap();
223 let content = "\
225function MyComponent() {
226 useEffect(() => {
227 setA(1);
228 setB(2);
229 }, []);
230 useEffect(() => {
231 setC(3);
232 setD(4);
233 }, []);
234 return <div />;
235}";
236 let violations = check(&rule, content);
237 assert!(violations.is_empty());
238 }
239
240 #[test]
241 fn non_set_state_calls_ignored() {
242 let rule = NoCascadingSetStateRule::new(&make_config(2)).unwrap();
243 let content = "\
244function MyComponent() {
245 useEffect(() => {
246 console.log('hi');
247 fetchData();
248 setup();
249 setA(1);
250 }, []);
251 return <div />;
252}";
253 let violations = check(&rule, content);
254 assert!(violations.is_empty());
255 }
256
257 #[test]
258 fn lowercase_set_not_counted() {
259 let rule = NoCascadingSetStateRule::new(&make_config(2)).unwrap();
260 let content = "\
262function MyComponent() {
263 useEffect(() => {
264 settings();
265 setup();
266 setA(1);
267 }, []);
268 return <div />;
269}";
270 let violations = check(&rule, content);
271 assert!(violations.is_empty());
272 }
273
274 #[test]
275 fn function_expression_callback() {
276 let rule = NoCascadingSetStateRule::new(&make_config(2)).unwrap();
277 let content = "\
278function MyComponent() {
279 useEffect(function() {
280 setA(1);
281 setB(2);
282 }, []);
283 return <div />;
284}";
285 let violations = check(&rule, content);
286 assert_eq!(violations.len(), 1);
287 }
288
289 #[test]
290 fn default_max_count() {
291 let config = RuleConfig {
292 id: "test".into(),
293 severity: Severity::Warning,
294 message: "test".into(),
295 ..Default::default()
296 };
297 let rule = NoCascadingSetStateRule::new(&config).unwrap();
298 assert_eq!(rule.max_count, 3);
299 }
300
301 #[test]
302 fn non_tsx_file_skipped() {
303 let rule = NoCascadingSetStateRule::new(&make_config(1)).unwrap();
304 let ctx = ScanContext {
305 file_path: Path::new("test.rs"),
306 content: "fn main() {}",
307 };
308 assert!(rule.check_file(&ctx).is_empty());
309 }
310
311 #[test]
312 fn is_set_state_name_cases() {
313 assert!(is_set_state_name("setA"));
314 assert!(is_set_state_name("setName"));
315 assert!(is_set_state_name("setIsLoading"));
316 assert!(!is_set_state_name("set"));
317 assert!(!is_set_state_name("setup"));
318 assert!(!is_set_state_name("settings"));
319 assert!(!is_set_state_name("reset"));
320 assert!(!is_set_state_name("useState"));
321 }
322}