Skip to main content

code_baseline/rules/ast/
max_component_size.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 components that exceed a configurable line count.
6///
7/// Walks the AST for function declarations, arrow functions, and class
8/// declarations with PascalCase names and reports any whose span
9/// (end_row - start_row + 1) exceeds `max_count` (default 150).
10pub struct MaxComponentSizeRule {
11    id: String,
12    severity: Severity,
13    message: String,
14    suggest: Option<String>,
15    glob: Option<String>,
16    max_count: usize,
17}
18
19impl MaxComponentSizeRule {
20    pub fn new(config: &RuleConfig) -> Result<Self, RuleBuildError> {
21        Ok(Self {
22            id: config.id.clone(),
23            severity: config.severity,
24            message: config.message.clone(),
25            suggest: config.suggest.clone(),
26            glob: config.glob.clone(),
27            max_count: config.max_count.unwrap_or(150),
28        })
29    }
30}
31
32impl Rule for MaxComponentSizeRule {
33    fn id(&self) -> &str {
34        &self.id
35    }
36
37    fn severity(&self) -> Severity {
38        self.severity
39    }
40
41    fn file_glob(&self) -> Option<&str> {
42        self.glob.as_deref()
43    }
44
45    fn check_file(&self, ctx: &ScanContext) -> Vec<Violation> {
46        let mut violations = Vec::new();
47        let tree = match parse_file(ctx.file_path, ctx.content) {
48            Some(t) => t,
49            None => return violations,
50        };
51        let source = ctx.content.as_bytes();
52        self.visit(tree.root_node(), source, ctx, &mut violations);
53        violations
54    }
55}
56
57impl MaxComponentSizeRule {
58    fn visit(
59        &self,
60        node: tree_sitter::Node,
61        source: &[u8],
62        ctx: &ScanContext,
63        violations: &mut Vec<Violation>,
64    ) {
65        if is_component_node(&node, source) {
66            let start = node.start_position().row;
67            let end = node.end_position().row;
68            let line_count = end - start + 1;
69
70            if line_count > self.max_count {
71                violations.push(Violation {
72                    rule_id: self.id.clone(),
73                    severity: self.severity,
74                    file: ctx.file_path.to_path_buf(),
75                    line: Some(start + 1),
76                    column: Some(1),
77                    message: self.message.clone(),
78                    suggest: self.suggest.clone(),
79                    source_line: ctx.content.lines().nth(start).map(String::from),
80                    fix: None,
81                });
82            }
83        }
84
85        for i in 0..node.child_count() {
86            if let Some(child) = node.child(i) {
87                self.visit(child, source, ctx, violations);
88            }
89        }
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use std::path::Path;
97
98    fn make_config(max_count: usize) -> RuleConfig {
99        RuleConfig {
100            id: "max-component-size".into(),
101            severity: Severity::Warning,
102            message: format!("Component exceeds {} lines", max_count),
103            suggest: Some("Split into smaller components".into()),
104            glob: Some("**/*.tsx".into()),
105            max_count: Some(max_count),
106            ..Default::default()
107        }
108    }
109
110    fn check(rule: &MaxComponentSizeRule, content: &str) -> Vec<Violation> {
111        let ctx = ScanContext {
112            file_path: Path::new("test.tsx"),
113            content,
114        };
115        rule.check_file(&ctx)
116    }
117
118    #[test]
119    fn small_component_no_violation() {
120        let rule = MaxComponentSizeRule::new(&make_config(10)).unwrap();
121        let content = "\
122function Small() {
123  return <div>hello</div>;
124}";
125        let violations = check(&rule, content);
126        assert!(violations.is_empty());
127    }
128
129    #[test]
130    fn component_at_exact_limit() {
131        let rule = MaxComponentSizeRule::new(&make_config(3)).unwrap();
132        // 3 lines exactly — should NOT trigger (> not >=)
133        let content = "\
134function AtLimit() {
135  return <div>hello</div>;
136}";
137        let violations = check(&rule, content);
138        assert!(violations.is_empty());
139    }
140
141    #[test]
142    fn component_over_limit() {
143        let rule = MaxComponentSizeRule::new(&make_config(3)).unwrap();
144        // 4 lines — exceeds max_count of 3
145        let content = "\
146function TooLong() {
147  const x = 1;
148  const y = 2;
149  return <div>{x}{y}</div>;
150}";
151        let violations = check(&rule, content);
152        assert_eq!(violations.len(), 1);
153        assert_eq!(violations[0].line, Some(1));
154    }
155
156    #[test]
157    fn arrow_function_component() {
158        let rule = MaxComponentSizeRule::new(&make_config(3)).unwrap();
159        let content = "\
160const Big = () => {
161  const a = 1;
162  const b = 2;
163  return <div />;
164};";
165        let violations = check(&rule, content);
166        assert_eq!(violations.len(), 1);
167    }
168
169    #[test]
170    fn class_component() {
171        let rule = MaxComponentSizeRule::new(&make_config(3)).unwrap();
172        let content = "\
173class BigClass extends React.Component {
174  render() {
175    const x = 1;
176    return <div />;
177  }
178}";
179        let violations = check(&rule, content);
180        assert_eq!(violations.len(), 1);
181    }
182
183    #[test]
184    fn lowercase_function_ignored() {
185        let rule = MaxComponentSizeRule::new(&make_config(3)).unwrap();
186        let content = "\
187function helper() {
188  const a = 1;
189  const b = 2;
190  const c = 3;
191  return a + b + c;
192}";
193        let violations = check(&rule, content);
194        assert!(violations.is_empty());
195    }
196
197    #[test]
198    fn non_tsx_file_skipped() {
199        let rule = MaxComponentSizeRule::new(&make_config(1)).unwrap();
200        let ctx = ScanContext {
201            file_path: Path::new("test.rs"),
202            content: "fn main() { println!(\"hello\"); }",
203        };
204        assert!(rule.check_file(&ctx).is_empty());
205    }
206
207    #[test]
208    fn default_max_count() {
209        let config = RuleConfig {
210            id: "test".into(),
211            severity: Severity::Warning,
212            message: "too big".into(),
213            ..Default::default()
214        };
215        let rule = MaxComponentSizeRule::new(&config).unwrap();
216        assert_eq!(rule.max_count, 150);
217    }
218}