Skip to main content

cha_core/plugins/
api_surface.rs

1use crate::{AnalysisContext, Finding, Location, Plugin, Severity, SmellCategory};
2
3/// Analyze the ratio of exported (public) API surface.
4pub struct ApiSurfaceAnalyzer {
5    pub max_exported_ratio: f64,
6    pub max_exported_count: usize,
7    /// Higher thresholds for C/C++ implementation files (.c/.cpp).
8    /// C modules legitimately export more symbols than typical OO classes.
9    pub c_max_exported_ratio: f64,
10    pub c_max_exported_count: usize,
11    /// Skip C/C++ header files (.h/.hpp). Headers are public-API by design,
12    /// so the "too many exported items" signal is meaningless for them.
13    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 ratio gate effectively off — `.c` files often export 100% of
22            // their non-static functions by design (the .h pairs the visibility).
23            // Only the count threshold matters.
24            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
72/// Check if path looks like a C/C++ header file.
73fn 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
80/// Count total exported functions and classes.
81fn 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    /// Build the large API surface finding.
89    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        // 25 exported + 5 private = 30 total, ratio 0.83. Rust threshold (20/0.80) fires;
178        // C threshold (30/0.95) should not.
179        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}