code_baseline/rules/ast/
no_regexp_in_render.rs1use crate::config::{RuleConfig, Severity};
2use crate::rules::ast::{is_component_node, parse_file};
3use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
4
5pub struct NoRegexpInRenderRule {
11 id: String,
12 severity: Severity,
13 message: String,
14 suggest: Option<String>,
15 glob: Option<String>,
16}
17
18impl NoRegexpInRenderRule {
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
30impl Rule for NoRegexpInRenderRule {
31 fn id(&self) -> &str {
32 &self.id
33 }
34 fn severity(&self) -> Severity {
35 self.severity
36 }
37 fn file_glob(&self) -> Option<&str> {
38 self.glob.as_deref()
39 }
40 fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
41 let mut violations = Vec::new();
42 let tree = match parse_file(ctx.file_path, ctx.content) {
43 Some(t) => t,
44 None => return violations,
45 };
46 let source = ctx.content.as_bytes();
47 self.find_components(tree.root_node(), source, ctx, &mut violations);
49 violations
50 }
51}
52
53impl NoRegexpInRenderRule {
54 fn find_components(
55 &self,
56 node: tree_sitter::Node,
57 source: &[u8],
58 ctx: &ScanContext,
59 violations: &mut Vec<Violation>,
60 ) {
61 if is_component_node(&node, source) {
62 self.find_new_regexp(node, source, ctx, violations, false);
64 return; }
66
67 for i in 0..node.child_count() {
68 if let Some(child) = node.child(i) {
69 self.find_components(child, source, ctx, violations);
70 }
71 }
72 }
73
74 fn find_new_regexp(
75 &self,
76 node: tree_sitter::Node,
77 source: &[u8],
78 ctx: &ScanContext,
79 violations: &mut Vec<Violation>,
80 in_memo: bool,
81 ) {
82 let entering_memo = !in_memo && is_memo_or_callback_call(&node, source);
84 let current_in_memo = in_memo || entering_memo;
85
86 if node.kind() == "new_expression" {
87 if let Some(constructor) = node.child_by_field_name("constructor") {
88 if let Ok(name) = constructor.utf8_text(source) {
89 if name == "RegExp" && !current_in_memo {
90 let line = node.start_position().row;
91 violations.push(Violation {
92 rule_id: self.id.clone(),
93 severity: self.severity,
94 file: ctx.file_path.to_path_buf(),
95 line: Some(line + 1),
96 column: Some(node.start_position().column + 1),
97 message: self.message.clone(),
98 suggest: self.suggest.clone(),
99 source_line: ctx.content.lines().nth(line).map(String::from),
100 fix: None,
101 });
102 }
103 }
104 }
105 }
106
107 for i in 0..node.child_count() {
108 if let Some(child) = node.child(i) {
109 if is_component_node(&child, source) {
111 continue;
112 }
113 self.find_new_regexp(child, source, ctx, violations, current_in_memo);
114 }
115 }
116 }
117}
118
119fn is_memo_or_callback_call(node: &tree_sitter::Node, source: &[u8]) -> bool {
120 if node.kind() == "call_expression" {
121 if let Some(func) = node.child_by_field_name("function") {
122 if func.kind() == "identifier" {
123 if let Ok(name) = func.utf8_text(source) {
124 return name == "useMemo" || name == "useCallback";
125 }
126 }
127 }
128 }
129 false
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135 use std::path::Path;
136
137 fn make_rule() -> NoRegexpInRenderRule {
138 NoRegexpInRenderRule::new(&RuleConfig {
139 id: "no-regexp-in-render".into(),
140 severity: Severity::Warning,
141 message: "new RegExp() in component body re-compiles every render".into(),
142 suggest: Some("Move to module scope or useMemo".into()),
143 glob: Some("**/*.{tsx,jsx}".into()),
144 ..Default::default()
145 })
146 .unwrap()
147 }
148
149 fn check(content: &str) -> Vec<Violation> {
150 let rule = make_rule();
151 let ctx = ScanContext {
152 file_path: Path::new("test.tsx"),
153 content,
154 };
155 rule.check_file(&ctx)
156 }
157
158 #[test]
159 fn new_regexp_in_component_flags() {
160 let content = "\
161function MyComponent({ pattern }) {
162 const re = new RegExp(pattern);
163 return <div>{re.test('abc') ? 'yes' : 'no'}</div>;
164}";
165 assert_eq!(check(content).len(), 1);
166 }
167
168 #[test]
169 fn new_regexp_at_module_scope_no_violation() {
170 let content = "\
171const EMAIL_RE = new RegExp('[^@]+@[^@]+');
172function MyComponent() {
173 return <div>{EMAIL_RE.test('a@b') ? 'yes' : 'no'}</div>;
174}";
175 assert!(check(content).is_empty());
176 }
177
178 #[test]
179 fn new_regexp_in_use_memo_no_violation() {
180 let content = "\
181function MyComponent({ pattern }) {
182 const re = useMemo(() => new RegExp(pattern), [pattern]);
183 return <div>{re.test('abc') ? 'yes' : 'no'}</div>;
184}";
185 assert!(check(content).is_empty());
186 }
187
188 #[test]
189 fn new_regexp_in_use_callback_no_violation() {
190 let content = "\
191function MyComponent({ pattern }) {
192 const test = useCallback(() => {
193 const re = new RegExp(pattern);
194 return re.test('abc');
195 }, [pattern]);
196 return <div />;
197}";
198 assert!(check(content).is_empty());
199 }
200
201 #[test]
202 fn non_component_function_no_violation() {
203 let content = "\
204function helper(pattern) {
205 return new RegExp(pattern);
206}";
207 assert!(check(content).is_empty());
208 }
209
210 #[test]
211 fn arrow_component_flags() {
212 let content = "\
213const MyComponent = () => {
214 const re = new RegExp('\\\\d+');
215 return <div />;
216};";
217 assert_eq!(check(content).len(), 1);
218 }
219
220 #[test]
221 fn non_tsx_skipped() {
222 let rule = make_rule();
223 let ctx = ScanContext {
224 file_path: Path::new("test.rs"),
225 content: "fn main() {}",
226 };
227 assert!(rule.check_file(&ctx).is_empty());
228 }
229}