code_baseline/rules/ast/
no_object_dep_array.rs1use crate::config::{RuleConfig, Severity};
2use crate::rules::ast::parse_file;
3use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
4
5pub struct NoObjectDepArrayRule {
11 id: String,
12 severity: Severity,
13 message: String,
14 suggest: Option<String>,
15 glob: Option<String>,
16}
17
18impl NoObjectDepArrayRule {
19 pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
20 Ok(Self {
21 id: config.id.clone(),
22 severity: config.severity,
23 message: config.message.clone(),
24 suggest: config.suggest.clone(),
25 glob: config.glob.clone(),
26 })
27 }
28}
29
30const HOOKS_WITH_DEPS: &[&str] = &["useEffect", "useMemo", "useCallback"];
31
32impl Rule for NoObjectDepArrayRule {
33 fn id(&self) -> &str {
34 &self.id
35 }
36 fn severity(&self) -> Severity {
37 self.severity
38 }
39 fn file_glob(&self) -> Option<&str> {
40 self.glob.as_deref()
41 }
42 fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
43 let mut violations = Vec::new();
44 let tree = match parse_file(ctx.file_path, ctx.content) {
45 Some(t) => t,
46 None => return violations,
47 };
48 let source = ctx.content.as_bytes();
49 self.visit(tree.root_node(), source, ctx, &mut violations);
50 violations
51 }
52}
53
54impl NoObjectDepArrayRule {
55 fn visit(
56 &self,
57 node: tree_sitter::Node,
58 source: &[u8],
59 ctx: &ScanContext,
60 violations: &mut Vec<Violation>,
61 ) {
62 if node.kind() == "call_expression" {
63 if let Some(func) = node.child_by_field_name("function") {
64 if func.kind() == "identifier" {
65 if let Ok(name) = func.utf8_text(source) {
66 if HOOKS_WITH_DEPS.contains(&name) {
67 if let Some(args) = node.child_by_field_name("arguments") {
68 if let Some(dep_array) = args.named_child(1) {
70 if dep_array.kind() == "array" {
71 self.check_dep_array(
72 &dep_array, source, ctx, violations,
73 );
74 }
75 }
76 }
77 }
78 }
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 fn check_dep_array(
91 &self,
92 array_node: &tree_sitter::Node,
93 _source: &[u8],
94 ctx: &ScanContext,
95 violations: &mut Vec<Violation>,
96 ) {
97 for i in 0..array_node.named_child_count() {
98 if let Some(elem) = array_node.named_child(i) {
99 if elem.kind() == "object" || elem.kind() == "array" {
100 let line = elem.start_position().row;
101 violations.push(Violation {
102 rule_id: self.id.clone(),
103 severity: self.severity,
104 file: ctx.file_path.to_path_buf(),
105 line: Some(line + 1),
106 column: Some(elem.start_position().column + 1),
107 message: self.message.clone(),
108 suggest: self.suggest.clone(),
109 source_line: ctx.content.lines().nth(line).map(String::from),
110 fix: None,
111 });
112 }
113 }
114 }
115 }
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use std::path::Path;
122
123 fn make_rule() -> NoObjectDepArrayRule {
124 NoObjectDepArrayRule::new(&RuleConfig {
125 id: "no-object-dep-array".into(),
126 severity: Severity::Warning,
127 message: "Object/array literal in dependency array".into(),
128 suggest: Some("Extract to useMemo or a ref".into()),
129 glob: Some("**/*.{tsx,jsx}".into()),
130 ..Default::default()
131 })
132 .unwrap()
133 }
134
135 fn check(content: &str) -> Vec<Violation> {
136 let rule = make_rule();
137 let ctx = ScanContext {
138 file_path: Path::new("test.tsx"),
139 content,
140 };
141 rule.check_file(&ctx)
142 }
143
144 #[test]
145 fn object_literal_in_use_effect_dep_flags() {
146 let content = "\
147function MyComponent({ a }) {
148 useEffect(() => {
149 doSomething();
150 }, [{ key: a }]);
151 return <div />;
152}";
153 assert_eq!(check(content).len(), 1);
154 }
155
156 #[test]
157 fn array_literal_in_use_memo_dep_flags() {
158 let content = "\
159function MyComponent({ items }) {
160 const result = useMemo(() => compute(items), [[1, 2, 3]]);
161 return <div />;
162}";
163 assert_eq!(check(content).len(), 1);
164 }
165
166 #[test]
167 fn identifier_deps_no_violation() {
168 let content = "\
169function MyComponent({ data }) {
170 useEffect(() => {
171 process(data);
172 }, [data]);
173 return <div />;
174}";
175 assert!(check(content).is_empty());
176 }
177
178 #[test]
179 fn empty_dep_array_no_violation() {
180 let content = "\
181function MyComponent() {
182 useEffect(() => {
183 init();
184 }, []);
185 return <div />;
186}";
187 assert!(check(content).is_empty());
188 }
189
190 #[test]
191 fn use_callback_with_object_dep_flags() {
192 let content = "\
193function MyComponent({ config }) {
194 const handler = useCallback(() => {
195 process(config);
196 }, [{ ...config }]);
197 return <div />;
198}";
199 assert_eq!(check(content).len(), 1);
200 }
201
202 #[test]
203 fn non_tsx_skipped() {
204 let rule = make_rule();
205 let ctx = ScanContext {
206 file_path: Path::new("test.rs"),
207 content: "fn main() {}",
208 };
209 assert!(rule.check_file(&ctx).is_empty());
210 }
211}