Skip to main content

code_baseline/rules/ast/
no_regexp_in_render.rs

1use crate::config::{RuleConfig, Severity};
2use crate::rules::ast::{is_component_node, parse_file};
3use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
4
5/// Flags `new RegExp()` calls inside React component function bodies.
6///
7/// Creating a RegExp inside a component body means it's re-compiled on every
8/// render.  The rule checks that the `new RegExp()` call is inside a component
9/// function and NOT at module scope, and not inside `useMemo`/`useCallback`.
10pub 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        // Find components, then search within them for new RegExp()
48        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            // Search this component's body for new RegExp() calls
63            self.find_new_regexp(node, source, ctx, violations, false);
64            return; // Don't recurse into nested components from here
65        }
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        // Check if we're entering a useMemo/useCallback
83        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                // Skip nested component definitions
110                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}