1use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory};
2
3pub struct ErrorHandlingAnalyzer {
22 pub max_unwraps_per_function: usize,
23}
24
25impl Default for ErrorHandlingAnalyzer {
26 fn default() -> Self {
27 Self {
28 max_unwraps_per_function: 3,
29 }
30 }
31}
32
33impl Plugin for ErrorHandlingAnalyzer {
34 fn name(&self) -> &str {
35 "error_handling"
36 }
37
38 fn smells(&self) -> Vec<String> {
39 vec!["empty_catch".into(), "unwrap_abuse".into()]
40 }
41
42 fn description(&self) -> &str {
43 "Empty catch blocks, unwrap/expect abuse"
44 }
45
46 fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
47 let mut findings = Vec::new();
48 let unwrap_sites = collect_unwrap_sites(ctx);
49 for f in &ctx.model.functions {
50 let in_fn: Vec<&UnwrapSite> = unwrap_sites
51 .iter()
52 .filter(|s| s.line >= f.start_line && s.line <= f.end_line)
53 .collect();
54 if in_fn.len() > self.max_unwraps_per_function {
55 for site in &in_fn {
56 findings.push(build_unwrap_finding(
57 ctx,
58 f,
59 site,
60 in_fn.len(),
61 self.max_unwraps_per_function,
62 ));
63 }
64 }
65 }
66 detect_empty_catch(ctx, &mut findings);
67 findings
68 }
69}
70
71struct UnwrapSite {
73 line: usize,
74 start_col: usize,
75 end_col: usize,
76 matched: String,
77}
78
79fn collect_unwrap_sites(ctx: &AnalysisContext) -> Vec<UnwrapSite> {
80 if let (Some(tree), Some(lang)) = (ctx.tree, ctx.ts_language) {
81 if ctx.model.language != "rust" {
82 return Vec::new();
85 }
86 let source = ctx.file.content.as_bytes();
87 let pattern = r#"(call_expression function: (field_expression field: (field_identifier) @m (#match? @m "^(unwrap|expect)$"))) @site"#;
88 let mut out = Vec::new();
89 for matches in crate::query::run_query(tree, lang, source, pattern) {
90 let Some(site_cap) = matches.iter().find(|c| c.capture_name == "site") else {
91 continue;
92 };
93 let m_cap = matches.iter().find(|c| c.capture_name == "m");
94 let matched = match m_cap {
95 Some(c) if c.text == "expect" => "expect",
96 _ => "unwrap",
97 }
98 .to_string();
99 out.push(UnwrapSite {
100 line: site_cap.start_line as usize,
101 start_col: site_cap.start_col as usize,
102 end_col: site_cap.end_col as usize,
103 matched,
104 });
105 }
106 return out;
107 }
108 collect_unwrap_sites_legacy(&ctx.file.content)
111}
112
113fn collect_unwrap_sites_legacy(content: &str) -> Vec<UnwrapSite> {
114 let mut out = Vec::new();
115 for (i, line) in content.lines().enumerate() {
116 let trimmed = line.trim();
117 if trimmed.starts_with("//") || trimmed.starts_with('#') {
118 continue;
119 }
120 push_legacy_match(&mut out, i + 1, line, ".unwrap()", "unwrap");
121 push_legacy_match(&mut out, i + 1, line, ".expect(", "expect");
122 }
123 out
124}
125
126fn push_legacy_match(
127 sites: &mut Vec<UnwrapSite>,
128 line: usize,
129 text: &str,
130 needle: &str,
131 matched: &str,
132) {
133 let mut search_from = 0;
134 while let Some(pos) = text[search_from..].find(needle) {
135 let abs = search_from + pos;
136 sites.push(UnwrapSite {
137 line,
138 start_col: abs,
139 end_col: abs + needle.len(),
140 matched: matched.to_string(),
141 });
142 search_from = abs + needle.len();
143 }
144}
145
146fn build_unwrap_finding(
147 ctx: &AnalysisContext,
148 f: &crate::FunctionInfo,
149 site: &UnwrapSite,
150 total: usize,
151 threshold: usize,
152) -> Finding {
153 Finding {
154 smell_name: "unwrap_abuse".into(),
155 category: SmellCategory::Security,
156 severity: Severity::Warning,
157 location: Location {
158 path: ctx.file.path.clone(),
159 start_line: site.line,
160 start_col: site.start_col,
161 end_line: site.line,
162 end_col: site.end_col,
163 name: Some(f.name.clone()),
164 },
165 message: format!(
166 "`.{}()` in `{}` (function has {total} unwrap/expect calls, threshold: {threshold})",
167 site.matched, f.name
168 ),
169 suggested_refactorings: vec!["Use ? operator".into(), "Handle errors explicitly".into()],
170 ..Default::default()
171 }
172}
173
174fn detect_empty_catch(ctx: &AnalysisContext, findings: &mut Vec<Finding>) {
177 let (Some(tree), Some(lang)) = (ctx.tree, ctx.ts_language) else {
178 return;
179 };
180 let source = ctx.file.content.as_bytes();
181 let patterns: &[&str] = match ctx.model.language.as_str() {
182 "typescript" => &["(catch_clause body: (statement_block) @body) @site"],
184 "python" => &["(except_clause body: (block) @body) @site"],
186 _ => return,
187 };
188 for pat in patterns {
189 for matches in crate::query::run_query(tree, lang, source, pat) {
190 let Some(body) = matches.iter().find(|c| c.capture_name == "body") else {
191 continue;
192 };
193 let Some(site) = matches.iter().find(|c| c.capture_name == "site") else {
194 continue;
195 };
196 if !is_body_empty(body) {
197 continue;
198 }
199 findings.push(Finding {
200 smell_name: "empty_catch".into(),
201 category: SmellCategory::Security,
202 severity: Severity::Warning,
203 location: Location {
204 path: ctx.file.path.clone(),
205 start_line: site.start_line as usize,
206 start_col: site.start_col as usize,
207 end_line: site.end_line as usize,
208 end_col: site.end_col as usize,
209 name: None,
210 },
211 message: "Empty catch/except block — errors are silently swallowed".into(),
212 suggested_refactorings: vec![
213 "Log the error".into(),
214 "Re-throw or handle explicitly".into(),
215 ],
216 ..Default::default()
217 });
218 }
219 }
220}
221
222fn is_body_empty(body: &crate::query::QueryMatch) -> bool {
223 let trimmed = body.text.trim();
224 trimmed == "{}" || trimmed == "pass" || trimmed.is_empty()
225}