use crate::config::{RuleConfig, Severity};
use crate::rules::ast::{is_component_node, parse_file};
use crate::rules::{Rule, RuleBuildError, ScanContext, Violation};
pub struct NoRegexpInRenderRule {
id: String,
severity: Severity,
message: String,
suggest: Option<String>,
glob: Option<String>,
}
impl NoRegexpInRenderRule {
pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
Ok(Self {
id: config.id.clone(),
severity: config.severity,
message: config.message.clone(),
suggest: config.suggest.clone(),
glob: config.glob.clone(),
})
}
}
impl Rule for NoRegexpInRenderRule {
fn id(&self) -> &str {
&self.id
}
fn severity(&self) -> Severity {
self.severity
}
fn file_glob(&self) -> Option<&str> {
self.glob.as_deref()
}
fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
let mut violations = Vec::new();
let tree = match parse_file(ctx.file_path, ctx.content) {
Some(t) => t,
None => return violations,
};
let source = ctx.content.as_bytes();
self.find_components(tree.root_node(), source, ctx, &mut violations);
violations
}
}
impl NoRegexpInRenderRule {
fn find_components(
&self,
node: tree_sitter::Node,
source: &[u8],
ctx: &ScanContext,
violations: &mut Vec<Violation>,
) {
if is_component_node(&node, source) {
self.find_new_regexp(node, source, ctx, violations, false);
return; }
for i in 0..node.child_count() {
if let Some(child) = node.child(i) {
self.find_components(child, source, ctx, violations);
}
}
}
fn find_new_regexp(
&self,
node: tree_sitter::Node,
source: &[u8],
ctx: &ScanContext,
violations: &mut Vec<Violation>,
in_memo: bool,
) {
let entering_memo = !in_memo && is_memo_or_callback_call(&node, source);
let current_in_memo = in_memo || entering_memo;
if node.kind() == "new_expression" {
if let Some(constructor) = node.child_by_field_name("constructor") {
if let Ok(name) = constructor.utf8_text(source) {
if name == "RegExp" && !current_in_memo {
let line = node.start_position().row;
violations.push(Violation {
rule_id: self.id.clone(),
severity: self.severity,
file: ctx.file_path.to_path_buf(),
line: Some(line + 1),
column: Some(node.start_position().column + 1),
message: self.message.clone(),
suggest: self.suggest.clone(),
source_line: ctx.content.lines().nth(line).map(String::from),
fix: None,
});
}
}
}
}
for i in 0..node.child_count() {
if let Some(child) = node.child(i) {
if is_component_node(&child, source) {
continue;
}
self.find_new_regexp(child, source, ctx, violations, current_in_memo);
}
}
}
}
fn is_memo_or_callback_call(node: &tree_sitter::Node, source: &[u8]) -> bool {
if node.kind() == "call_expression" {
if let Some(func) = node.child_by_field_name("function") {
if func.kind() == "identifier" {
if let Ok(name) = func.utf8_text(source) {
return name == "useMemo" || name == "useCallback";
}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn make_rule() -> NoRegexpInRenderRule {
NoRegexpInRenderRule::new(&RuleConfig {
id: "no-regexp-in-render".into(),
severity: Severity::Warning,
message: "new RegExp() in component body re-compiles every render".into(),
suggest: Some("Move to module scope or useMemo".into()),
glob: Some("**/*.{tsx,jsx}".into()),
..Default::default()
})
.unwrap()
}
fn check(content: &str) -> Vec<Violation> {
let rule = make_rule();
let ctx = ScanContext {
file_path: Path::new("test.tsx"),
content,
};
rule.check_file(&ctx)
}
#[test]
fn new_regexp_in_component_flags() {
let content = "\
function MyComponent({ pattern }) {
const re = new RegExp(pattern);
return <div>{re.test('abc') ? 'yes' : 'no'}</div>;
}";
assert_eq!(check(content).len(), 1);
}
#[test]
fn new_regexp_at_module_scope_no_violation() {
let content = "\
const EMAIL_RE = new RegExp('[^@]+@[^@]+');
function MyComponent() {
return <div>{EMAIL_RE.test('a@b') ? 'yes' : 'no'}</div>;
}";
assert!(check(content).is_empty());
}
#[test]
fn new_regexp_in_use_memo_no_violation() {
let content = "\
function MyComponent({ pattern }) {
const re = useMemo(() => new RegExp(pattern), [pattern]);
return <div>{re.test('abc') ? 'yes' : 'no'}</div>;
}";
assert!(check(content).is_empty());
}
#[test]
fn new_regexp_in_use_callback_no_violation() {
let content = "\
function MyComponent({ pattern }) {
const test = useCallback(() => {
const re = new RegExp(pattern);
return re.test('abc');
}, [pattern]);
return <div />;
}";
assert!(check(content).is_empty());
}
#[test]
fn non_component_function_no_violation() {
let content = "\
function helper(pattern) {
return new RegExp(pattern);
}";
assert!(check(content).is_empty());
}
#[test]
fn arrow_component_flags() {
let content = "\
const MyComponent = () => {
const re = new RegExp('\\\\d+');
return <div />;
};";
assert_eq!(check(content).len(), 1);
}
#[test]
fn non_tsx_skipped() {
let rule = make_rule();
let ctx = ScanContext {
file_path: Path::new("test.rs"),
content: "fn main() {}",
};
assert!(rule.check_file(&ctx).is_empty());
}
}