Skip to main content

code_baseline/rules/ast/
prefer_use_reducer.rs

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
5/// Flags React components that have too many `useState` calls.
6///
7/// When a component accumulates many independent `useState` calls, it often
8/// means the state values are related and would be better modelled as a single
9/// `useReducer`.  The threshold is configurable via `max_count` (default 4).
10pub 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        // Outer has 2 useState, Inner has 2 useState — neither reaches 3
178        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}