Skip to main content

rscheck_cli/rules/file_complexity/
mod.rs

1use crate::analysis::Workspace;
2use crate::config::{ComplexityMode, FileComplexityConfig};
3use crate::emit::Emitter;
4use crate::report::{FileMetrics, Finding};
5use crate::rules::{Rule, RuleBackend, RuleContext, RuleFamily, RuleInfo};
6use crate::span::{Location, Span};
7use std::path::Path;
8use syn::visit::Visit;
9
10pub struct FileComplexityRule;
11
12impl FileComplexityRule {
13    pub fn static_info() -> RuleInfo {
14        RuleInfo {
15            id: "shape.file_complexity",
16            family: RuleFamily::Shape,
17            backend: RuleBackend::Syntax,
18            summary: "Measures file and function complexity against configured limits.",
19            default_level: FileComplexityConfig::default().level,
20            schema: "level, mode, max_file, max_fn, count_question, match_arms",
21            config_example: "[rules.\"shape.file_complexity\"]\nlevel = \"warn\"\nmode = \"cyclomatic\"\nmax_file = 200\nmax_fn = 25",
22            fixable: false,
23        }
24    }
25}
26
27impl Rule for FileComplexityRule {
28    fn info(&self) -> RuleInfo {
29        Self::static_info()
30    }
31
32    fn run(&self, ws: &Workspace, ctx: &RuleContext<'_>, out: &mut dyn Emitter) {
33        for file in &ws.files {
34            let cfg = match ctx
35                .policy
36                .decode_rule::<FileComplexityConfig>(Self::static_info().id, Some(&file.path))
37            {
38                Ok(cfg) => cfg,
39                Err(_) => continue,
40            };
41            let Some(ast) = &file.ast else { continue };
42
43            match cfg.mode {
44                ComplexityMode::Cyclomatic => {
45                    let mut v = CyclomaticVisitor {
46                        count_question: cfg.count_question,
47                        match_arms: cfg.match_arms,
48                        per_fn: Vec::new(),
49                    };
50                    v.visit_file(ast);
51
52                    let sum = v.per_fn.iter().map(|c| c.score).sum::<u32>();
53                    let max_fn = v.per_fn.iter().map(|c| c.score).max().unwrap_or(0);
54
55                    out.record_metrics(FileMetrics {
56                        path: file.path.to_string_lossy().to_string(),
57                        cyclomatic_sum: sum,
58                        cyclomatic_max_fn: max_fn,
59                    });
60
61                    let over_file = sum > cfg.max_file;
62                    let over_fn = max_fn > cfg.max_fn;
63                    if over_file || over_fn {
64                        let mut msg = String::new();
65                        if over_file {
66                            msg.push_str(&format!(
67                                "file cyclomatic complexity sum {sum} exceeds {}\n",
68                                cfg.max_file
69                            ));
70                        }
71                        if over_fn {
72                            msg.push_str(&format!(
73                                "max function cyclomatic complexity {max_fn} exceeds {}\n",
74                                cfg.max_fn
75                            ));
76                        }
77                        out.emit(Finding {
78                            rule_id: Self::static_info().id.to_string(),
79                            family: Some(Self::static_info().family),
80                            engine: Some(Self::static_info().backend),
81                            severity: cfg.level.to_severity(),
82                            message: msg.trim_end().to_string(),
83                            primary: Some(file_span(&file.path)),
84                            secondary: Vec::new(),
85                            help: Some(
86                                "Split the functions or module to reduce branching.".to_string(),
87                            ),
88                            evidence: Some(format_per_fn(&v.per_fn)),
89                            confidence: None,
90                            tags: vec!["complexity".to_string()],
91                            labels: Vec::new(),
92                            notes: Vec::new(),
93                            fixes: Vec::new(),
94                        });
95                    }
96                }
97                ComplexityMode::PhysicalLoc => {
98                    let loc = count_physical_loc(&file.text);
99                    if loc as u32 > cfg.max_file {
100                        out.emit(Finding {
101                            rule_id: Self::static_info().id.to_string(),
102                            family: Some(Self::static_info().family),
103                            engine: Some(Self::static_info().backend),
104                            severity: cfg.level.to_severity(),
105                            message: format!("file physical LOC {loc} exceeds {}", cfg.max_file),
106                            primary: Some(file_span(&file.path)),
107                            secondary: Vec::new(),
108                            help: Some("Split the file into smaller modules.".to_string()),
109                            evidence: None,
110                            confidence: None,
111                            tags: vec!["size".to_string()],
112                            labels: Vec::new(),
113                            notes: Vec::new(),
114                            fixes: Vec::new(),
115                        });
116                    }
117                }
118                ComplexityMode::LogicalLoc => {
119                    let mut v = LogicalLocVisitor { stmts: 0 };
120                    v.visit_file(ast);
121                    let ll = v.stmts;
122                    if ll > cfg.max_file {
123                        out.emit(Finding {
124                            rule_id: Self::static_info().id.to_string(),
125                            family: Some(Self::static_info().family),
126                            engine: Some(Self::static_info().backend),
127                            severity: cfg.level.to_severity(),
128                            message: format!("file logical LOC {ll} exceeds {}", cfg.max_file),
129                            primary: Some(file_span(&file.path)),
130                            secondary: Vec::new(),
131                            help: Some("Split the file into smaller modules.".to_string()),
132                            evidence: None,
133                            confidence: None,
134                            tags: vec!["size".to_string()],
135                            labels: Vec::new(),
136                            notes: Vec::new(),
137                            fixes: Vec::new(),
138                        });
139                    }
140                }
141            }
142        }
143    }
144}
145
146fn file_span(path: &Path) -> Span {
147    Span::new(
148        path,
149        Location { line: 1, column: 1 },
150        Location { line: 1, column: 1 },
151    )
152}
153
154fn format_per_fn(per_fn: &[FnScore]) -> String {
155    let mut out = String::new();
156    for s in per_fn {
157        out.push_str(&format!("{}: {}\n", s.name, s.score));
158    }
159    out
160}
161
162fn count_physical_loc(text: &str) -> usize {
163    text.lines()
164        .filter_map(|line| {
165            let t = line.trim();
166            if t.is_empty() {
167                return None;
168            }
169            if t.starts_with("//") {
170                return None;
171            }
172            Some(())
173        })
174        .count()
175}
176
177#[derive(Debug, Clone)]
178struct FnScore {
179    name: String,
180    score: u32,
181}
182
183struct CyclomaticVisitor {
184    count_question: bool,
185    match_arms: bool,
186    per_fn: Vec<FnScore>,
187}
188
189impl CyclomaticVisitor {
190    fn bump(&mut self, n: u32) {
191        if let Some(last) = self.per_fn.last_mut() {
192            last.score = last.score.saturating_add(n);
193        }
194    }
195}
196
197impl<'ast> Visit<'ast> for CyclomaticVisitor {
198    fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
199        let name = node.sig.ident.to_string();
200        self.per_fn.push(FnScore { name, score: 1 });
201        syn::visit::visit_item_fn(self, node);
202    }
203
204    fn visit_impl_item_fn(&mut self, node: &'ast syn::ImplItemFn) {
205        let name = node.sig.ident.to_string();
206        self.per_fn.push(FnScore { name, score: 1 });
207        syn::visit::visit_impl_item_fn(self, node);
208    }
209
210    fn visit_expr_if(&mut self, node: &'ast syn::ExprIf) {
211        self.bump(1);
212        syn::visit::visit_expr_if(self, node);
213    }
214
215    fn visit_expr_for_loop(&mut self, node: &'ast syn::ExprForLoop) {
216        self.bump(1);
217        syn::visit::visit_expr_for_loop(self, node);
218    }
219
220    fn visit_expr_while(&mut self, node: &'ast syn::ExprWhile) {
221        self.bump(1);
222        syn::visit::visit_expr_while(self, node);
223    }
224
225    fn visit_expr_loop(&mut self, node: &'ast syn::ExprLoop) {
226        self.bump(1);
227        syn::visit::visit_expr_loop(self, node);
228    }
229
230    fn visit_expr_match(&mut self, node: &'ast syn::ExprMatch) {
231        if self.match_arms {
232            self.bump(node.arms.len() as u32);
233        } else {
234            self.bump(1);
235        }
236        syn::visit::visit_expr_match(self, node);
237    }
238
239    fn visit_expr_binary(&mut self, node: &'ast syn::ExprBinary) {
240        if matches!(node.op, syn::BinOp::And(_) | syn::BinOp::Or(_)) {
241            self.bump(1);
242        }
243        syn::visit::visit_expr_binary(self, node);
244    }
245
246    fn visit_expr_try(&mut self, node: &'ast syn::ExprTry) {
247        if self.count_question {
248            self.bump(1);
249        }
250        syn::visit::visit_expr_try(self, node);
251    }
252}
253
254struct LogicalLocVisitor {
255    stmts: u32,
256}
257
258impl<'ast> Visit<'ast> for LogicalLocVisitor {
259    fn visit_stmt(&mut self, node: &'ast syn::Stmt) {
260        self.stmts = self.stmts.saturating_add(1);
261        syn::visit::visit_stmt(self, node);
262    }
263}
264
265#[cfg(test)]
266mod tests;