rscheck_cli/rules/file_complexity/
mod.rs1use 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;