1use crate::{
2 analyzers::Analyzer,
3 collector::{FileSnapshot, RepositorySnapshot},
4 model::CodeQuality,
5};
6use syn::{
7 spanned::Spanned,
8 visit::{self, Visit},
9 BinOp, ExprBinary, ExprForLoop, ExprIf, ExprMatch, ExprWhile, ImplItemFn, ItemFn, TraitItemFn,
10};
11
12pub struct CodeQualityAnalyzer;
13
14impl Analyzer<CodeQuality> for CodeQualityAnalyzer {
15 fn analyze(&self, snapshot: &RepositorySnapshot) -> CodeQuality {
16 let rust_files = snapshot.rust_files().collect::<Vec<_>>();
17 let module_metrics = rust_files
18 .iter()
19 .map(|file| module_metrics(file))
20 .collect::<Vec<_>>();
21 let lines_of_code = module_metrics
22 .iter()
23 .map(|metrics| metrics.code_lines)
24 .sum::<usize>();
25 let module_count = rust_files.len();
26 let function_count = module_metrics
27 .iter()
28 .map(|metrics| metrics.function_count)
29 .sum::<usize>();
30 let function_lines = module_metrics
31 .iter()
32 .flat_map(|metrics| metrics.function_lines.iter().copied())
33 .collect::<Vec<_>>();
34 let average_function_size = if function_lines.is_empty() {
35 0.0
36 } else {
37 function_lines.iter().sum::<usize>() as f32 / function_lines.len() as f32
38 };
39
40 let module_lines = module_metrics
41 .iter()
42 .map(|metrics| metrics.lines)
43 .collect::<Vec<_>>();
44 let p90_module_lines = percentile(&module_lines, 90);
45 let large_module_floor = 300;
46 let large_module_threshold = p90_module_lines.max(large_module_floor);
47 let god_module_line_threshold = ((p90_module_lines as f32 * 1.5).ceil() as usize).max(500);
48 let module_function_counts = module_metrics
49 .iter()
50 .map(|metrics| metrics.function_count)
51 .collect::<Vec<_>>();
52 let god_module_function_threshold = percentile(&module_function_counts, 90).max(30);
53
54 let large_modules = rust_files
55 .iter()
56 .zip(module_metrics.iter())
57 .filter(|(_, metrics)| metrics.lines >= large_module_threshold)
58 .map(|(file, _)| format!("{} ({} lines)", file.relative_path, file.lines))
59 .collect::<Vec<_>>();
60 let god_module_candidates = rust_files
61 .iter()
62 .zip(module_metrics.iter())
63 .filter(|(_, metrics)| {
64 metrics.lines >= god_module_line_threshold
65 || metrics.function_count >= god_module_function_threshold
66 })
67 .map(|(file, _)| file.relative_path.clone())
68 .collect::<Vec<_>>();
69
70 let mut complexity_indicators = Vec::new();
71 for (file, metrics) in rust_files.iter().zip(module_metrics.iter()) {
72 let branch_density = if metrics.code_lines == 0 {
73 0.0
74 } else {
75 metrics.branch_count as f32 / metrics.code_lines as f32
76 };
77 if metrics.branch_count > 80 && branch_density > 0.08 {
78 complexity_indicators.push(format!(
79 "{} has high branching density ({} indicators)",
80 file.relative_path, metrics.branch_count
81 ));
82 }
83 }
84
85 let mut score = 100_i32;
86 let module_denominator = module_count.max(1) as f32;
87 let large_module_density = large_modules.len() as f32 / module_denominator;
88 let god_module_density = god_module_candidates.len() as f32 / module_denominator;
89 let complexity_density = complexity_indicators.len() as f32 / module_denominator;
90 score -= ((large_module_density * 30.0).round() as i32).min(30);
91 score -= ((god_module_density * 45.0).round() as i32).min(45);
92 score -= ((complexity_density * 25.0).round() as i32).min(25);
93 score -= if average_function_size > 60.0 { 12 } else { 0 };
94
95 CodeQuality {
96 lines_of_code,
97 module_count,
98 function_count,
99 average_function_size,
100 complexity_indicators,
101 large_modules,
102 god_module_candidates,
103 score: score.clamp(0, 100) as u8,
104 }
105 }
106}
107
108#[derive(Debug)]
109struct ModuleMetrics {
110 lines: usize,
111 code_lines: usize,
112 function_count: usize,
113 function_lines: Vec<usize>,
114 branch_count: usize,
115}
116
117fn count_code_lines(content: &str) -> usize {
118 content
119 .lines()
120 .map(str::trim)
121 .filter(|line| !line.is_empty() && !line.starts_with("//"))
122 .count()
123}
124
125fn module_metrics(file: &FileSnapshot) -> ModuleMetrics {
126 let content = file.content.as_deref().unwrap_or_default();
127 let parsed = if file.lines <= 500 {
128 parse_syntax_metrics(content).ok()
129 } else {
130 None
131 };
132 let function_lines = parsed
133 .as_ref()
134 .map(|metrics| metrics.function_lines.clone())
135 .filter(|lines| !lines.is_empty())
136 .unwrap_or_else(|| estimate_function_sizes(content));
137 let function_count = parsed
138 .as_ref()
139 .map(|metrics| metrics.function_count)
140 .unwrap_or_else(|| count_function_declarations_textually(content));
141 let branch_count = parsed
142 .as_ref()
143 .map(|metrics| metrics.branch_count)
144 .unwrap_or_else(|| count_branch_indicators_textually(content));
145
146 ModuleMetrics {
147 lines: file.lines,
148 code_lines: count_code_lines(content),
149 function_count,
150 function_lines,
151 branch_count,
152 }
153}
154
155#[derive(Default)]
156struct SyntaxMetrics {
157 function_count: usize,
158 function_lines: Vec<usize>,
159 branch_count: usize,
160}
161
162impl<'ast> Visit<'ast> for SyntaxMetrics {
163 fn visit_item_fn(&mut self, node: &'ast ItemFn) {
164 self.push_function(node.span());
165 visit::visit_item_fn(self, node);
166 }
167
168 fn visit_impl_item_fn(&mut self, node: &'ast ImplItemFn) {
169 self.push_function(node.span());
170 visit::visit_impl_item_fn(self, node);
171 }
172
173 fn visit_trait_item_fn(&mut self, node: &'ast TraitItemFn) {
174 self.push_function(node.span());
175 visit::visit_trait_item_fn(self, node);
176 }
177
178 fn visit_expr_if(&mut self, node: &'ast ExprIf) {
179 self.branch_count += 1;
180 visit::visit_expr_if(self, node);
181 }
182
183 fn visit_expr_match(&mut self, node: &'ast ExprMatch) {
184 self.branch_count += 1;
185 visit::visit_expr_match(self, node);
186 }
187
188 fn visit_expr_while(&mut self, node: &'ast ExprWhile) {
189 self.branch_count += 1;
190 visit::visit_expr_while(self, node);
191 }
192
193 fn visit_expr_for_loop(&mut self, node: &'ast ExprForLoop) {
194 self.branch_count += 1;
195 visit::visit_expr_for_loop(self, node);
196 }
197
198 fn visit_expr_binary(&mut self, node: &'ast ExprBinary) {
199 if matches!(node.op, BinOp::And(_) | BinOp::Or(_)) {
200 self.branch_count += 1;
201 }
202 visit::visit_expr_binary(self, node);
203 }
204}
205
206impl SyntaxMetrics {
207 fn push_function(&mut self, span: proc_macro2::Span) {
208 let start = span.start().line;
209 let end = span.end().line;
210 self.function_count += 1;
211 self.function_lines
212 .push(end.saturating_sub(start).max(0) + 1);
213 }
214}
215
216fn parse_syntax_metrics(content: &str) -> Result<SyntaxMetrics, syn::Error> {
217 let syntax = syn::parse_file(content)?;
218 let mut metrics = SyntaxMetrics::default();
219 metrics.visit_file(&syntax);
220 Ok(metrics)
221}
222
223fn count_function_declarations_textually(content: &str) -> usize {
224 estimate_function_sizes(content).len()
225}
226
227fn count_branch_indicators_textually(content: &str) -> usize {
228 [" if ", " match ", " while ", " for ", "&&", "||"]
229 .iter()
230 .map(|needle| content.matches(needle).count())
231 .sum::<usize>()
232}
233
234fn percentile(values: &[usize], percentile: usize) -> usize {
235 if values.is_empty() {
236 return 0;
237 }
238 let mut values = values.to_vec();
239 values.sort_unstable();
240 let rank = ((values.len() as f32 - 1.0) * percentile as f32 / 100.0).ceil() as usize;
241 values[rank.min(values.len() - 1)]
242}
243
244fn estimate_function_sizes(content: &str) -> Vec<usize> {
245 let lines = content.lines().collect::<Vec<_>>();
246 let mut sizes = Vec::new();
247 for (idx, line) in lines.iter().enumerate() {
248 if line.trim_start().starts_with("fn ")
249 || line.trim_start().starts_with("pub fn ")
250 || line.trim_start().starts_with("async fn ")
251 || line.trim_start().starts_with("pub async fn ")
252 {
253 let mut depth = 0_i32;
254 let mut seen_body = false;
255 for (end_idx, candidate) in lines.iter().enumerate().skip(idx) {
256 for ch in candidate.chars() {
257 if ch == '{' {
258 depth += 1;
259 seen_body = true;
260 } else if ch == '}' {
261 depth -= 1;
262 }
263 }
264 if seen_body && depth <= 0 {
265 sizes.push(end_idx - idx + 1);
266 break;
267 }
268 }
269 }
270 }
271 sizes
272}
273
274#[cfg(test)]
275mod tests {
276 use std::path::PathBuf;
277
278 use crate::{
279 analyzers::{code_quality::parse_syntax_metrics, Analyzer},
280 collector::{CargoManifest, FileSnapshot, RepositorySnapshot},
281 };
282
283 use super::CodeQualityAnalyzer;
284
285 #[test]
286 fn empty_repository_scores_without_panicking() {
287 let report = CodeQualityAnalyzer.analyze(&snapshot(Vec::new()));
288
289 assert_eq!(report.module_count, 0);
290 assert_eq!(report.function_count, 0);
291 assert_eq!(report.score, 100);
292 }
293
294 #[test]
295 fn typical_repository_uses_parser_backed_function_counts() {
296 let report = CodeQualityAnalyzer.analyze(&snapshot(vec![(
297 "src/lib.rs",
298 r#"
299pub fn one() {}
300
301impl Thing {
302 pub async fn two(&self) {
303 if true {}
304 }
305}
306"#,
307 )]));
308
309 assert_eq!(report.function_count, 2);
310 assert!(report.average_function_size >= 1.0);
311 assert!(report.score >= 90);
312 }
313
314 #[test]
315 fn extreme_repository_penalizes_density_not_absolute_count() {
316 let mut files = Vec::new();
317 for index in 0..10 {
318 let content = if index < 2 {
319 repeated_functions(80)
320 } else {
321 "pub fn small() {}\n".to_string()
322 };
323 files.push((format!("src/module_{index}.rs"), content));
324 }
325 let files = files
326 .iter()
327 .map(|(path, content)| (path.as_str(), content.as_str()))
328 .collect::<Vec<_>>();
329
330 let report = CodeQualityAnalyzer.analyze(&snapshot(files));
331
332 assert_eq!(report.god_module_candidates.len(), 2);
333 assert!(report.score > 40);
334 assert!(report.score < 100);
335 }
336
337 #[test]
338 fn adversarial_comments_do_not_count_as_functions_when_syn_parses() {
339 let metrics = parse_syntax_metrics(
340 r#"
341// fn fake() {}
342pub fn real() {}
343"#,
344 )
345 .expect("valid Rust should parse");
346
347 assert_eq!(metrics.function_count, 1);
348 }
349
350 fn snapshot(files: Vec<(&str, &str)>) -> RepositorySnapshot {
351 RepositorySnapshot {
352 root: PathBuf::from("/tmp/repo"),
353 files: files
354 .into_iter()
355 .map(|(relative_path, content)| FileSnapshot {
356 path: PathBuf::from("/tmp/repo").join(relative_path),
357 relative_path: relative_path.to_string(),
358 extension: Some("rs".into()),
359 bytes: content.len() as u64,
360 lines: content.lines().count(),
361 content: Some(content.to_string()),
362 })
363 .collect(),
364 manifests: Vec::<CargoManifest>::new(),
365 }
366 }
367
368 fn repeated_functions(count: usize) -> String {
369 let mut content = String::new();
370 for index in 0..count {
371 content.push_str(&format!("pub fn function_{index}() {{}}\n"));
372 }
373 content
374 }
375}