Skip to main content

rta_core/analyzers/
code_quality.rs

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}