1use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory};
2
3pub struct ApiSurfaceAnalyzer {
5 pub max_exported_ratio: f64,
6 pub max_exported_count: usize,
7 pub c_max_exported_ratio: f64,
10 pub c_max_exported_count: usize,
11 pub skip_c_headers: bool,
14}
15
16impl Default for ApiSurfaceAnalyzer {
17 fn default() -> Self {
18 Self {
19 max_exported_ratio: 0.8,
20 max_exported_count: 20,
21 c_max_exported_ratio: 1.01,
25 c_max_exported_count: 30,
26 skip_c_headers: true,
27 }
28 }
29}
30
31impl Plugin for ApiSurfaceAnalyzer {
32 fn name(&self) -> &str {
33 "api_surface"
34 }
35
36 fn smells(&self) -> Vec<String> {
37 vec!["large_api_surface".into()]
38 }
39
40 fn description(&self) -> &str {
41 "Exported ratio too high, narrow the public API"
42 }
43
44 fn analyze(&self, ctx: &AnalysisContext) -> Vec<Finding> {
45 let is_c_like = matches!(ctx.model.language.as_str(), "c" | "cpp");
46 if is_c_like && self.skip_c_headers && is_header_file(&ctx.file.path) {
47 return vec![];
48 }
49
50 let total = ctx.model.functions.len() + ctx.model.classes.len();
51 if total < 5 {
52 return vec![];
53 }
54
55 let exported = count_exported(ctx);
56 let ratio = exported as f64 / total as f64;
57
58 let (max_count, max_ratio) = if is_c_like {
59 (self.c_max_exported_count, self.c_max_exported_ratio)
60 } else {
61 (self.max_exported_count, self.max_exported_ratio)
62 };
63
64 if exported > max_count || ratio > max_ratio {
65 vec![self.make_finding(ctx, exported, total, ratio, max_ratio)]
66 } else {
67 vec![]
68 }
69 }
70}
71
72fn is_header_file(path: &std::path::Path) -> bool {
74 matches!(
75 path.extension().and_then(|e| e.to_str()),
76 Some("h" | "hpp" | "hxx" | "hh" | "h++")
77 )
78}
79
80fn count_exported(ctx: &AnalysisContext) -> usize {
82 let fns = ctx.model.functions.iter().filter(|f| f.is_exported).count();
83 let cls = ctx.model.classes.iter().filter(|c| c.is_exported).count();
84 fns + cls
85}
86
87impl ApiSurfaceAnalyzer {
88 fn make_finding(
90 &self,
91 ctx: &AnalysisContext,
92 exported: usize,
93 total: usize,
94 ratio: f64,
95 threshold: f64,
96 ) -> Finding {
97 Finding {
98 smell_name: "large_api_surface".into(),
99 category: SmellCategory::Bloaters,
100 severity: Severity::Warning,
101 location: Location {
102 path: ctx.file.path.clone(),
103 start_line: 1,
104 end_line: 1,
105 name: None,
106 ..Default::default()
107 },
108 message: format!(
109 "File exports {}/{} items ({:.0}%), consider narrowing the public API",
110 exported,
111 total,
112 ratio * 100.0
113 ),
114 suggested_refactorings: vec!["Hide Method".into(), "Extract Class".into()],
115 actual_value: Some(ratio),
116 threshold: Some(threshold),
117 risk_score: None,
118 }
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125 use crate::{FunctionInfo, SourceFile, SourceModel};
126 use std::path::PathBuf;
127
128 fn exported_fn(name: &str) -> FunctionInfo {
129 FunctionInfo {
130 name: name.into(),
131 is_exported: true,
132 start_line: 1,
133 end_line: 2,
134 line_count: 2,
135 ..Default::default()
136 }
137 }
138
139 fn make_ctx<'a>(file: &'a SourceFile, model: &'a SourceModel) -> AnalysisContext<'a> {
140 AnalysisContext {
141 file,
142 model,
143 tree: None,
144 ts_language: None,
145 project: None,
146 }
147 }
148
149 #[test]
150 fn skips_c_header_with_100_percent_exports() {
151 let file = SourceFile::new(PathBuf::from("foo.h"), String::new());
152 let model = SourceModel {
153 language: "c".into(),
154 functions: (0..30).map(|i| exported_fn(&format!("f{}", i))).collect(),
155 ..Default::default()
156 };
157 let ctx = make_ctx(&file, &model);
158 let findings = ApiSurfaceAnalyzer::default().analyze(&ctx);
159 assert!(findings.is_empty(), "should skip .h files");
160 }
161
162 #[test]
163 fn skips_cpp_header() {
164 let file = SourceFile::new(PathBuf::from("foo.hpp"), String::new());
165 let model = SourceModel {
166 language: "cpp".into(),
167 functions: (0..30).map(|i| exported_fn(&format!("f{}", i))).collect(),
168 ..Default::default()
169 };
170 let ctx = make_ctx(&file, &model);
171 assert!(ApiSurfaceAnalyzer::default().analyze(&ctx).is_empty());
172 }
173
174 #[test]
175 fn c_impl_uses_higher_threshold() {
176 let file = SourceFile::new(PathBuf::from("foo.c"), String::new());
177 let mut funcs: Vec<FunctionInfo> =
180 (0..25).map(|i| exported_fn(&format!("f{}", i))).collect();
181 for i in 0..5 {
182 let mut p = exported_fn(&format!("p{}", i));
183 p.is_exported = false;
184 funcs.push(p);
185 }
186 let model = SourceModel {
187 language: "c".into(),
188 functions: funcs,
189 ..Default::default()
190 };
191 let ctx = make_ctx(&file, &model);
192 let findings = ApiSurfaceAnalyzer::default().analyze(&ctx);
193 assert!(
194 findings.is_empty(),
195 "C .c with 25/30 exports should not fire"
196 );
197 }
198
199 #[test]
200 fn rust_still_uses_default_threshold() {
201 let file = SourceFile::new(PathBuf::from("foo.rs"), String::new());
202 let model = SourceModel {
203 language: "rust".into(),
204 functions: (0..25).map(|i| exported_fn(&format!("f{}", i))).collect(),
205 ..Default::default()
206 };
207 let ctx = make_ctx(&file, &model);
208 let findings = ApiSurfaceAnalyzer::default().analyze(&ctx);
209 assert_eq!(findings.len(), 1, "Rust 25 exports > 20 should fire");
210 }
211
212 #[test]
213 fn c_impl_above_c_threshold_fires() {
214 let file = SourceFile::new(PathBuf::from("foo.c"), String::new());
215 let model = SourceModel {
216 language: "c".into(),
217 functions: (0..35).map(|i| exported_fn(&format!("f{}", i))).collect(),
218 ..Default::default()
219 };
220 let ctx = make_ctx(&file, &model);
221 let findings = ApiSurfaceAnalyzer::default().analyze(&ctx);
222 assert_eq!(
223 findings.len(),
224 1,
225 "C .c file with 35 exports > 30 should fire"
226 );
227 }
228
229 #[test]
230 fn skip_c_headers_can_be_disabled() {
231 let file = SourceFile::new(PathBuf::from("foo.h"), String::new());
232 let model = SourceModel {
233 language: "c".into(),
234 functions: (0..35).map(|i| exported_fn(&format!("f{}", i))).collect(),
235 ..Default::default()
236 };
237 let ctx = make_ctx(&file, &model);
238 let analyzer = ApiSurfaceAnalyzer {
239 skip_c_headers: false,
240 ..Default::default()
241 };
242 let findings = analyzer.analyze(&ctx);
243 assert_eq!(findings.len(), 1, "header should fire when skip is off");
244 }
245}