Skip to main content

sofie_check/
god_type.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3use std::path::PathBuf;
4
5use proc_macro2::{LineColumn, TokenStream, TokenTree};
6use sofie_model::syntax::use_paths;
7use syn::{ImplItem, Item, ItemImpl, Type, Visibility};
8
9use crate::count_as_f64;
10use crate::lines::LineKind;
11use crate::workspace::{CrateSource, SourceFile};
12
13/// Divisors, not cutoffs: the score reads as "how many times over", summed across axes.
14const CODE_LINES_THRESHOLD: f64 = 300.0;
15const METHODS_THRESHOLD: f64 = 15.0;
16const DEPENDENCIES_THRESHOLD: f64 = 12.0;
17const PUBLIC_METHODS_THRESHOLD: f64 = 10.0;
18
19const INTERNAL_PATH_ROOTS: [&str; 2] = ["crate", "super"];
20const WORKSPACE_CRATE_PREFIX: &str = "sofie_";
21
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
23pub struct TypeMetrics {
24    pub code_lines: usize,
25    pub methods: usize,
26    pub public_methods: usize,
27    pub dependencies: usize,
28}
29
30impl TypeMetrics {
31    #[must_use]
32    pub fn score(self) -> f64 {
33        count_as_f64(self.code_lines) / CODE_LINES_THRESHOLD
34            + count_as_f64(self.methods) / METHODS_THRESHOLD
35            + count_as_f64(self.dependencies) / DEPENDENCIES_THRESHOLD
36            + count_as_f64(self.public_methods) / PUBLIC_METHODS_THRESHOLD
37    }
38}
39
40#[derive(Debug)]
41pub struct GodType {
42    pub type_name: String,
43    pub crate_name: String,
44    pub files: Vec<PathBuf>,
45    pub metrics: TypeMetrics,
46}
47
48impl GodType {
49    #[must_use]
50    pub fn score(&self) -> f64 {
51        self.metrics.score()
52    }
53}
54
55impl fmt::Display for GodType {
56    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
57        let files: Vec<String> = self
58            .files
59            .iter()
60            .map(|file| file.display().to_string())
61            .collect();
62        write!(
63            formatter,
64            "{}::{} score {:.2} ({} code lines, {} methods, {} public, {} dependencies) in {}",
65            self.crate_name,
66            self.type_name,
67            self.score(),
68            self.metrics.code_lines,
69            self.metrics.methods,
70            self.metrics.public_methods,
71            self.metrics.dependencies,
72            files.join(", ")
73        )
74    }
75}
76
77#[must_use]
78pub fn rank_god_types(crates: &[CrateSource]) -> Vec<GodType> {
79    let mut ranking: Vec<GodType> = crates.iter().flat_map(crate_god_types).collect();
80    ranking.sort_by(|left, right| right.score().total_cmp(&left.score()));
81    ranking
82}
83
84#[derive(Default)]
85struct TypeAccumulator {
86    metrics: TypeMetrics,
87    dependencies: BTreeSet<String>,
88    files: BTreeSet<PathBuf>,
89}
90
91fn crate_god_types(crate_source: &CrateSource) -> Vec<GodType> {
92    let mut types: BTreeMap<String, TypeAccumulator> = BTreeMap::new();
93    for file in &crate_source.files {
94        add_file_impls(file, &mut types);
95    }
96    types
97        .into_iter()
98        .map(|(type_name, accumulator)| GodType {
99            type_name,
100            crate_name: crate_source.name.clone(),
101            metrics: TypeMetrics {
102                dependencies: accumulator.dependencies.len(),
103                ..accumulator.metrics
104            },
105            files: accumulator.files.into_iter().collect(),
106        })
107        .collect()
108}
109
110fn add_file_impls(file: &SourceFile, types: &mut BTreeMap<String, TypeAccumulator>) {
111    let mut impls = Vec::new();
112    collect_inherent_impls(&file.syntax.items, &mut impls);
113    let mut imports = Vec::new();
114    collect_internal_imports(&file.syntax.items, &mut imports);
115    let identifiers = located_identifiers(file.tokens.clone());
116    for item_impl in impls {
117        let Some(type_name) = self_type_name(&item_impl.self_ty) else {
118            continue;
119        };
120        let accumulator = types.entry(type_name).or_default();
121        let metrics = &mut accumulator.metrics;
122        metrics.code_lines += impl_code_lines(item_impl, &file.lines);
123        metrics.methods += item_impl
124            .items
125            .iter()
126            .filter(|item| matches!(item, ImplItem::Fn(_)))
127            .count();
128        metrics.public_methods += public_method_count(item_impl);
129        accumulator
130            .dependencies
131            .extend(imports_used_by(item_impl, &imports, &identifiers));
132        accumulator.files.insert(file.path.clone());
133    }
134}
135
136fn collect_inherent_impls<'syntax>(items: &'syntax [Item], found: &mut Vec<&'syntax ItemImpl>) {
137    for item in items {
138        match item {
139            Item::Impl(item_impl) if item_impl.trait_.is_none() => found.push(item_impl),
140            Item::Mod(module) => {
141                if let Some((_, module_items)) = &module.content {
142                    collect_inherent_impls(module_items, found);
143                }
144            }
145            _ => {}
146        }
147    }
148}
149
150fn self_type_name(self_type: &Type) -> Option<String> {
151    let Type::Path(type_path) = self_type else {
152        return None;
153    };
154    type_path
155        .path
156        .segments
157        .last()
158        .map(|segment| segment.ident.to_string())
159}
160
161fn impl_code_lines(item_impl: &ItemImpl, line_kinds: &[LineKind]) -> usize {
162    let first_line = item_impl.impl_token.span.start().line;
163    let last_line = item_impl.brace_token.span.close().end().line;
164    line_kinds[first_line - 1..last_line]
165        .iter()
166        .filter(|kind| **kind == LineKind::Code)
167        .count()
168}
169
170fn public_method_count(item_impl: &ItemImpl) -> usize {
171    item_impl
172        .items
173        .iter()
174        .filter(|item| matches!(item, ImplItem::Fn(method) if matches!(method.vis, Visibility::Public(_))))
175        .count()
176}
177
178struct Import {
179    path: String,
180    /// `None` for a glob, whose names cannot be told apart, so it always counts.
181    binding: Option<String>,
182}
183
184fn collect_internal_imports(items: &[Item], found: &mut Vec<Import>) {
185    for item in items {
186        match item {
187            Item::Use(item_use) => found.extend(
188                use_paths(&item_use.tree)
189                    .into_iter()
190                    .filter(|used| is_internal_path(&used.segments))
191                    .map(|used| {
192                        let glob = used.binding.is_none().then(|| "*".to_owned());
193                        Import {
194                            path: used
195                                .segments
196                                .into_iter()
197                                .chain(glob)
198                                .collect::<Vec<_>>()
199                                .join("::"),
200                            binding: used.binding,
201                        }
202                    }),
203            ),
204            Item::Mod(module) => {
205                if let Some((_, module_items)) = &module.content {
206                    collect_internal_imports(module_items, found);
207                }
208            }
209            _ => {}
210        }
211    }
212}
213
214/// A file's imports serve all of its items, so only the imported names an impl mentions are its coupling.
215fn imports_used_by(
216    item_impl: &ItemImpl,
217    imports: &[Import],
218    identifiers: &[(LineColumn, String)],
219) -> Vec<String> {
220    let start = item_impl.impl_token.span.start();
221    let end = item_impl.brace_token.span.close().end();
222    let mentioned: BTreeSet<&str> = identifiers
223        .iter()
224        .filter(|(position, _)| (start..end).contains(position))
225        .map(|(_, identifier)| identifier.as_str())
226        .collect();
227    imports
228        .iter()
229        .filter(|import| {
230            import
231                .binding
232                .as_deref()
233                .is_none_or(|binding| mentioned.contains(binding))
234        })
235        .map(|import| import.path.clone())
236        .collect()
237}
238
239fn located_identifiers(tokens: TokenStream) -> Vec<(LineColumn, String)> {
240    let mut found = Vec::new();
241    for token in tokens {
242        match token {
243            TokenTree::Ident(identifier) => {
244                found.push((identifier.span().start(), identifier.to_string()));
245            }
246            TokenTree::Group(group) => found.extend(located_identifiers(group.stream())),
247            TokenTree::Punct(_) | TokenTree::Literal(_) => {}
248        }
249    }
250    found
251}
252
253fn is_internal_path(segments: &[String]) -> bool {
254    segments.first().is_some_and(|root| {
255        INTERNAL_PATH_ROOTS.contains(&root.as_str()) || root.starts_with(WORKSPACE_CRATE_PREFIX)
256    })
257}
258
259#[cfg(test)]
260mod tests {
261    use std::path::PathBuf;
262
263    use super::{GodType, TypeMetrics, rank_god_types};
264    use crate::workspace::{CrateSource, SourceFile};
265
266    fn crate_with(files: &[(&str, &str)]) -> CrateSource {
267        CrateSource {
268            name: "sofie-sample".to_owned(),
269            files: files
270                .iter()
271                .map(|(path, text)| {
272                    SourceFile::parse(PathBuf::from(path), text).expect("sample sources parse")
273                })
274                .collect(),
275        }
276    }
277
278    fn rank(files: &[(&str, &str)]) -> Vec<GodType> {
279        rank_god_types(&[crate_with(files)])
280    }
281
282    #[test]
283    fn score_follows_the_weighted_formula() {
284        let metrics = TypeMetrics {
285            code_lines: 300,
286            methods: 15,
287            public_methods: 10,
288            dependencies: 12,
289        };
290        assert!((metrics.score() - 4.0).abs() < 1e-9);
291    }
292
293    #[test]
294    fn measures_methods_public_methods_and_code_lines() {
295        let source = "struct Widget;\nimpl Widget {\n    // why\n    pub fn a() {}\n\n    fn b() {}\n    pub(crate) fn c() {}\n    const D: u8 = 1;\n}\n";
296        let ranking = rank(&[("lib.rs", source)]);
297        assert_eq!(ranking.len(), 1);
298        assert_eq!(ranking[0].type_name, "Widget");
299        let expected = TypeMetrics {
300            code_lines: 6,
301            methods: 3,
302            public_methods: 1,
303            dependencies: 0,
304        };
305        assert_eq!(ranking[0].metrics, expected);
306    }
307
308    #[test]
309    fn merges_impls_of_one_type_across_files_and_ignores_trait_impls() {
310        let first = "use crate::a::B;\nimpl Widget { fn a() {} }\n";
311        let second = "use super::{c::D, e::*};\nimpl crate::Widget { fn b() {} }\nimpl Clone for Widget { fn clone(&self) -> Self { todo!() } }\n";
312        let ranking = rank(&[("a.rs", first), ("b.rs", second)]);
313        assert_eq!(ranking.len(), 1);
314        assert_eq!(ranking[0].metrics.methods, 2);
315        assert_eq!(ranking[0].metrics.code_lines, 2);
316        assert_eq!(
317            ranking[0].files,
318            [PathBuf::from("a.rs"), PathBuf::from("b.rs")]
319        );
320    }
321
322    #[test]
323    fn counts_the_internal_imports_the_impls_mention_and_every_glob() {
324        let source = "use std::fmt;\nuse sofie_core::Thing;\nuse crate::{a::{self, B}, a::B as C, unused::D, e::*};\nmod inner { use super::x::Y; }\nimpl Widget { fn f(_: fmt::Debug, _: Thing, _: a::Z, _: B, _: C, _: Y) {} }\nimpl Other {}\n";
325        let ranking = rank(&[("lib.rs", source)]);
326        let dependencies = |name: &str| {
327            ranking
328                .iter()
329                .find(|god_type| god_type.type_name == name)
330                .map(|god_type| god_type.metrics.dependencies)
331        };
332        assert_eq!(dependencies("Widget"), Some(5));
333        assert_eq!(dependencies("Other"), Some(1));
334    }
335
336    #[test]
337    fn finds_impls_inside_inline_modules_and_ranks_worst_first() {
338        let source =
339            "impl Small {}\nmod inner {\n    impl Large { pub fn a() {} pub fn b() {} }\n}\n";
340        let ranking = rank(&[("lib.rs", source)]);
341        let names: Vec<&str> = ranking
342            .iter()
343            .map(|god_type| god_type.type_name.as_str())
344            .collect();
345        assert_eq!(names, ["Large", "Small"]);
346    }
347}