Skip to main content

cha_core/plugins/
error_handling.rs

1use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory};
2
3/// Detect error handling smells:
4/// - Empty catch/except blocks (silently swallowed errors)
5/// - Excessive unwrap()/expect() calls in Rust
6///
7/// ## References
8///
9/// [1] G. Padua and W. Shang, "Revisiting Exception Handling Practices
10///     with Exception Flow Analysis," Empirical Software Engineering,
11///     vol. 23, no. 6, pp. 3337–3383, 2018.
12///     doi: 10.1007/s10664-018-9601-8.
13///
14/// [2] A. Rahman, C. Parnin, and L. Williams, "The Seven Sins: Security
15///     Smells in Infrastructure as Code Scripts," in Proc. 41st Int. Conf.
16///     Software Engineering (ICSE), 2019, pp. 164–175.
17///     doi: 10.1109/ICSE.2019.00033.
18pub struct ErrorHandlingAnalyzer {
19    pub max_unwraps_per_function: usize,
20}
21
22impl Default for ErrorHandlingAnalyzer {
23    fn default() -> Self {
24        Self {
25            max_unwraps_per_function: 3,
26        }
27    }
28}
29
30impl Plugin for ErrorHandlingAnalyzer {
31    fn name(&self) -> &str {
32        "error_handling"
33    }
34
35    fn smells(&self) -> Vec<String> {
36        vec!["empty_catch".into(), "unwrap_abuse".into()]
37    }
38
39    fn description(&self) -> &str {
40        "Empty catch blocks, unwrap/expect abuse"
41    }
42
43    fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
44        let mut findings = Vec::new();
45        let lines: Vec<&str> = ctx.file.content.lines().collect();
46
47        for f in &ctx.model.functions {
48            let unwrap_count = count_unwraps(&lines, f.start_line, f.end_line);
49            if unwrap_count > self.max_unwraps_per_function {
50                findings.push(Finding {
51                    smell_name: "unwrap_abuse".into(),
52                    category: SmellCategory::Security,
53                    severity: Severity::Warning,
54                    location: Location {
55                        path: ctx.file.path.clone(),
56                        start_line: f.start_line,
57                        start_col: f.name_col,
58                        end_line: f.start_line,
59                        end_col: f.name_end_col,
60                        name: Some(f.name.clone()),
61                    },
62                    message: format!(
63                        "Function `{}` has {} unwrap/expect calls (threshold: {})",
64                        f.name, unwrap_count, self.max_unwraps_per_function
65                    ),
66                    suggested_refactorings: vec![
67                        "Use ? operator".into(),
68                        "Handle errors explicitly".into(),
69                    ],
70                    ..Default::default()
71                });
72            }
73        }
74
75        detect_empty_catch(&lines, ctx, &mut findings);
76        findings
77    }
78}
79
80fn count_unwraps(lines: &[&str], start: usize, end: usize) -> usize {
81    let mut count = 0;
82    for line in lines
83        .iter()
84        .take(end.min(lines.len()))
85        .skip(start.saturating_sub(1))
86    {
87        let trimmed = line.trim();
88        if trimmed.starts_with("//") || trimmed.starts_with('#') {
89            continue;
90        }
91        count += line.matches(".unwrap()").count();
92        count += line.matches(".expect(").count();
93    }
94    count
95}
96
97fn detect_empty_catch(lines: &[&str], ctx: &AnalysisContext, findings: &mut Vec<Finding>) {
98    for (i, line) in lines.iter().enumerate() {
99        let trimmed = line.trim();
100        let is_catch = trimmed.starts_with("catch")
101            || trimmed.starts_with("except")
102            || trimmed.starts_with("} catch")
103            || trimmed.starts_with("rescue");
104        if !is_catch {
105            continue;
106        }
107        if let Some(next) = lines.iter().skip(i + 1).find(|l| !l.trim().is_empty()) {
108            let next_trimmed = next.trim();
109            if next_trimmed == "}" || next_trimmed == "pass" || next_trimmed.is_empty() {
110                let col = line.find(trimmed).unwrap_or(0);
111                findings.push(Finding {
112                    smell_name: "empty_catch".into(),
113                    category: SmellCategory::Security,
114                    severity: Severity::Warning,
115                    location: Location {
116                        path: ctx.file.path.clone(),
117                        start_line: i + 1,
118                        start_col: col,
119                        end_line: i + 2,
120                        name: None,
121                        ..Default::default()
122                    },
123                    message: "Empty catch/except block — errors are silently swallowed".into(),
124                    suggested_refactorings: vec![
125                        "Log the error".into(),
126                        "Re-throw or handle explicitly".into(),
127                    ],
128                    ..Default::default()
129                });
130            }
131        }
132    }
133}