Skip to main content

code_baseline/rules/ast/
no_nested_components.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 React component definitions that appear inside another component.
6///
7/// Nested component definitions cause the inner component to be re-created on
8/// every render of the outer component, destroying and remounting its DOM and
9/// losing all state.
10pub struct NoNestedComponentsRule {
11    id: String,
12    severity: Severity,
13    message: String,
14    suggest: Option<String>,
15    glob: Option<String>,
16}
17
18impl NoNestedComponentsRule {
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 NoNestedComponentsRule {
31    fn id(&self) -> &str {
32        &self.id
33    }
34
35    fn severity(&self) -> Severity {
36        self.severity
37    }
38
39    fn file_glob(&self) -> Option<&str> {
40        self.glob.as_deref()
41    }
42
43    fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
44        let mut violations = Vec::new();
45        let tree = match parse_file(ctx.file_path, ctx.content) {
46            Some(t) => t,
47            None => return violations,
48        };
49        let source = ctx.content.as_bytes();
50        self.visit(tree.root_node(), source, ctx, &mut violations);
51        violations
52    }
53}
54
55impl NoNestedComponentsRule {
56    fn visit(
57        &self,
58        node: tree_sitter::Node,
59        source: &[u8],
60        ctx: &ScanContext,
61        violations: &mut Vec<Violation>,
62    ) {
63        if is_component_node(&node, source) && has_component_ancestor(&node, source) {
64            let line = node.start_position().row;
65            violations.push(Violation {
66                rule_id: self.id.clone(),
67                severity: self.severity,
68                file: ctx.file_path.to_path_buf(),
69                line: Some(line + 1),
70                column: Some(node.start_position().column + 1),
71                message: self.message.clone(),
72                suggest: self.suggest.clone(),
73                source_line: ctx.content.lines().nth(line).map(String::from),
74                fix: None,
75            });
76        }
77
78        for i in 0..node.child_count() {
79            if let Some(child) = node.child(i) {
80                self.visit(child, source, ctx, violations);
81            }
82        }
83    }
84}
85
86/// Walk up the parent chain to see if any ancestor is a component node.
87fn has_component_ancestor(node: &tree_sitter::Node, source: &[u8]) -> bool {
88    let mut current = node.parent();
89    while let Some(parent) = current {
90        if is_component_node(&parent, source) {
91            return true;
92        }
93        current = parent.parent();
94    }
95    false
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use std::path::Path;
102
103    fn make_config() -> RuleConfig {
104        RuleConfig {
105            id: "no-nested-components".into(),
106            severity: Severity::Error,
107            message: "Nested component definition".into(),
108            suggest: Some("Move the component to the module level".into()),
109            glob: Some("**/*.tsx".into()),
110            ..Default::default()
111        }
112    }
113
114    fn check(rule: &NoNestedComponentsRule, content: &str) -> Vec<Violation> {
115        let ctx = ScanContext {
116            file_path: Path::new("test.tsx"),
117            content,
118        };
119        rule.check_file(&ctx)
120    }
121
122    #[test]
123    fn no_nesting_no_violation() {
124        let rule = NoNestedComponentsRule::new(&make_config()).unwrap();
125        let content = "\
126function Outer() {
127  return <div>hello</div>;
128}
129
130function Other() {
131  return <span>world</span>;
132}";
133        let violations = check(&rule, content);
134        assert!(violations.is_empty());
135    }
136
137    #[test]
138    fn nested_function_declaration() {
139        let rule = NoNestedComponentsRule::new(&make_config()).unwrap();
140        let content = "\
141function Outer() {
142  function Inner() {
143    return <span>nested</span>;
144  }
145  return <div><Inner /></div>;
146}";
147        let violations = check(&rule, content);
148        assert_eq!(violations.len(), 1);
149        assert_eq!(violations[0].line, Some(2));
150    }
151
152    #[test]
153    fn nested_arrow_component() {
154        let rule = NoNestedComponentsRule::new(&make_config()).unwrap();
155        let content = "\
156const Outer = () => {
157  const Inner = () => {
158    return <div>nested</div>;
159  };
160  return <Inner />;
161};";
162        let violations = check(&rule, content);
163        assert_eq!(violations.len(), 1);
164    }
165
166    #[test]
167    fn nested_inside_class_component() {
168        let rule = NoNestedComponentsRule::new(&make_config()).unwrap();
169        let content = "\
170class Outer extends React.Component {
171  render() {
172    function Inner() {
173      return <span />;
174    }
175    return <Inner />;
176  }
177}";
178        let violations = check(&rule, content);
179        assert_eq!(violations.len(), 1);
180    }
181
182    #[test]
183    fn lowercase_nested_function_ignored() {
184        let rule = NoNestedComponentsRule::new(&make_config()).unwrap();
185        let content = "\
186function Outer() {
187  function helper() {
188    return 42;
189  }
190  return <div>{helper()}</div>;
191}";
192        let violations = check(&rule, content);
193        assert!(violations.is_empty());
194    }
195
196    #[test]
197    fn deeply_nested_components() {
198        let rule = NoNestedComponentsRule::new(&make_config()).unwrap();
199        let content = "\
200function A() {
201  function B() {
202    function C() {
203      return <div />;
204    }
205    return <C />;
206  }
207  return <B />;
208}";
209        let violations = check(&rule, content);
210        // B is nested in A, C is nested in B (and A)
211        assert_eq!(violations.len(), 2);
212    }
213
214    #[test]
215    fn non_tsx_file_skipped() {
216        let rule = NoNestedComponentsRule::new(&make_config()).unwrap();
217        let ctx = ScanContext {
218            file_path: Path::new("test.rs"),
219            content: "fn main() {}",
220        };
221        assert!(rule.check_file(&ctx).is_empty());
222    }
223}