Skip to main content

whitaker_common/lcom4/
mod.rs

1//! LCOM4 cohesion analysis for method relationship graphs.
2//!
3//! LCOM4 (Lack of Cohesion in Methods, version 4) measures type cohesion by
4//! modelling each method as a node in an undirected graph. Edges connect
5//! methods that share a field access or where one method directly calls
6//! another on the same type. The metric equals the number of connected
7//! components: LCOM4 == 1 indicates high cohesion, while LCOM4 >= 2
8//! suggests the type bundles unrelated responsibilities.
9//!
10//! This module provides a pure library helper that operates on pre-extracted
11//! method metadata (`MethodInfo`). It does not depend on `rustc_private` or
12//! any HIR types — the HIR traversal that populates `MethodInfo` is handled
13//! by individual lint drivers.
14//!
15//! See `docs/brain-trust-lints-design.md` §Cohesion analysis (LCOM4) for the
16//! full design rationale.
17
18use std::collections::{BTreeSet, HashMap};
19
20/// Metadata for a single method, used to build the LCOM4 method graph.
21///
22/// Each method carries its name, the set of fields it accesses, and the set
23/// of other methods on the same type that it calls directly. Field names and
24/// method names are plain strings extracted by the caller from HIR or other
25/// analysis passes.
26///
27/// # Examples
28///
29/// ```
30/// use std::collections::BTreeSet;
31/// use whitaker_common::lcom4::MethodInfo;
32///
33/// let method = MethodInfo::new(
34///     "process",
35///     BTreeSet::from(["data".into(), "buffer".into()]),
36///     BTreeSet::new(),
37/// );
38///
39/// assert_eq!(method.name(), "process");
40/// assert!(method.accessed_fields().contains("data"));
41/// assert!(method.called_methods().is_empty());
42/// ```
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub struct MethodInfo {
45    name: String,
46    accessed_fields: BTreeSet<String>,
47    called_methods: BTreeSet<String>,
48}
49
50impl MethodInfo {
51    /// Creates a new `MethodInfo` with the given name, accessed fields, and
52    /// called methods.
53    ///
54    /// # Examples
55    ///
56    /// ```
57    /// use std::collections::BTreeSet;
58    /// use whitaker_common::lcom4::MethodInfo;
59    ///
60    /// let m = MethodInfo::new(
61    ///     "process",
62    ///     BTreeSet::from(["data".into()]),
63    ///     BTreeSet::from(["validate".into()]),
64    /// );
65    /// assert_eq!(m.name(), "process");
66    /// ```
67    #[must_use]
68    pub fn new(
69        name: impl Into<String>,
70        accessed_fields: BTreeSet<String>,
71        called_methods: BTreeSet<String>,
72    ) -> Self {
73        Self {
74            name: name.into(),
75            accessed_fields,
76            called_methods,
77        }
78    }
79
80    /// Returns the method name.
81    ///
82    /// # Examples
83    ///
84    /// ```
85    /// use std::collections::BTreeSet;
86    /// use whitaker_common::lcom4::MethodInfo;
87    ///
88    /// let m = MethodInfo::new("read", BTreeSet::new(), BTreeSet::new());
89    /// assert_eq!(m.name(), "read");
90    /// ```
91    #[must_use]
92    pub fn name(&self) -> &str {
93        &self.name
94    }
95
96    /// Returns the set of field names accessed by this method.
97    ///
98    /// # Examples
99    ///
100    /// ```
101    /// use std::collections::BTreeSet;
102    /// use whitaker_common::lcom4::MethodInfo;
103    ///
104    /// let m = MethodInfo::new(
105    ///     "read",
106    ///     BTreeSet::from(["buf".into()]),
107    ///     BTreeSet::new(),
108    /// );
109    /// assert!(m.accessed_fields().contains("buf"));
110    /// ```
111    #[must_use]
112    pub fn accessed_fields(&self) -> &BTreeSet<String> {
113        &self.accessed_fields
114    }
115
116    /// Returns the set of method names called directly by this method.
117    ///
118    /// # Examples
119    ///
120    /// ```
121    /// use std::collections::BTreeSet;
122    /// use whitaker_common::lcom4::MethodInfo;
123    ///
124    /// let m = MethodInfo::new(
125    ///     "process",
126    ///     BTreeSet::new(),
127    ///     BTreeSet::from(["validate".into()]),
128    /// );
129    /// assert!(m.called_methods().contains("validate"));
130    /// ```
131    #[must_use]
132    pub fn called_methods(&self) -> &BTreeSet<String> {
133        &self.called_methods
134    }
135}
136
137/// Disjoint-set forest for connected component counting.
138///
139/// Uses path compression and union-by-rank for near-constant amortized
140/// operations. This is an internal implementation detail of
141/// [`cohesion_components`] and is not exposed publicly.
142struct UnionFind {
143    parent: Vec<usize>,
144    rank: Vec<usize>,
145}
146
147impl UnionFind {
148    fn new(n: usize) -> Self {
149        Self {
150            parent: (0..n).collect(),
151            rank: vec![0; n],
152        }
153    }
154
155    fn find(&mut self, x: usize) -> usize {
156        if self.parent[x] != x {
157            self.parent[x] = self.find(self.parent[x]);
158        }
159        self.parent[x]
160    }
161
162    /// Returns `true` when the first root has strictly lower rank.
163    fn lower_rank(&self, root_a: usize, root_b: usize) -> bool {
164        self.rank[root_a] < self.rank[root_b]
165    }
166
167    fn union(&mut self, x: usize, y: usize) {
168        let root_x = self.find(x);
169        let root_y = self.find(y);
170        if root_x == root_y {
171            return;
172        }
173        if self.lower_rank(root_x, root_y) {
174            self.parent[root_x] = root_y;
175        } else if self.lower_rank(root_y, root_x) {
176            self.parent[root_y] = root_x;
177        } else {
178            self.parent[root_y] = root_x;
179            self.rank[root_x] += 1;
180        }
181    }
182
183    fn component_count(&mut self) -> usize {
184        let n = self.parent.len();
185        // Flatten the forest so every node points directly to its root.
186        for i in 0..n {
187            self.find(i);
188        }
189        let mut roots: Vec<usize> = self.parent[..n].to_vec();
190        roots.sort_unstable();
191        roots.dedup();
192        roots.len()
193    }
194}
195
196/// Builds an index mapping each field name to the methods that access it.
197fn build_field_index<'a>(methods: &'a [MethodInfo]) -> HashMap<&'a str, Vec<usize>> {
198    let mut index: HashMap<&'a str, Vec<usize>> = HashMap::new();
199    for (idx, method) in methods.iter().enumerate() {
200        for field in method.accessed_fields() {
201            index.entry(field.as_str()).or_default().push(idx);
202        }
203    }
204    index
205}
206
207/// Unions all methods in the given index list with the first method.
208fn union_methods_by_index(indices: &[usize], uf: &mut UnionFind) {
209    if let Some((&first, rest)) = indices.split_first() {
210        for &other in rest {
211            uf.union(first, other);
212        }
213    }
214}
215
216/// Builds a field-to-method index and unions methods that share fields.
217fn union_by_shared_fields(methods: &[MethodInfo], uf: &mut UnionFind) {
218    let field_index = build_field_index(methods);
219    for indices in field_index.values() {
220        union_methods_by_index(indices, uf);
221    }
222}
223
224/// Builds a method-name-to-indices map, preserving duplicate names.
225fn build_method_index<'a>(methods: &'a [MethodInfo]) -> HashMap<&'a str, Vec<usize>> {
226    let mut index: HashMap<&'a str, Vec<usize>> = HashMap::new();
227    for (idx, method) in methods.iter().enumerate() {
228        index.entry(method.name()).or_default().push(idx);
229    }
230    index
231}
232
233/// Unions caller/callee pairs using the method-name index.
234///
235/// When multiple methods share a name (e.g. trait impl methods on the
236/// same type), the caller is unioned with every matching callee.
237/// Calls to names not present in the input are silently ignored.
238fn union_by_method_calls(methods: &[MethodInfo], uf: &mut UnionFind) {
239    let method_index = build_method_index(methods);
240
241    for (caller_idx, method) in methods.iter().enumerate() {
242        let callee_indices: Vec<usize> = method
243            .called_methods()
244            .iter()
245            .filter_map(|name| method_index.get(name.as_str()))
246            .flatten()
247            .copied()
248            .collect();
249        for callee_idx in callee_indices {
250            uf.union(caller_idx, callee_idx);
251        }
252    }
253}
254
255/// Counts connected components in the method relationship graph (LCOM4).
256///
257/// Returns `0` for an empty method slice, `1` when all methods form a
258/// single cohesive group, and `n >= 2` when the type contains `n` unrelated
259/// method clusters.
260///
261/// Two methods are connected when they share at least one field name in
262/// their accessed-fields sets, or when one method's called-methods set
263/// contains the other's name. When multiple methods share a name (e.g.
264/// trait impl methods), a call to that name connects the caller with
265/// every matching method. Calls to names not present in the input slice
266/// are silently ignored.
267///
268/// # Examples
269///
270/// ```
271/// use std::collections::BTreeSet;
272/// use whitaker_common::lcom4::{MethodInfo, cohesion_components};
273///
274/// let methods = vec![
275///     MethodInfo::new("read", BTreeSet::from(["buf".into()]), BTreeSet::new()),
276///     MethodInfo::new("write", BTreeSet::from(["buf".into()]), BTreeSet::new()),
277/// ];
278///
279/// assert_eq!(cohesion_components(&methods), 1);
280/// ```
281///
282/// ```
283/// use std::collections::BTreeSet;
284/// use whitaker_common::lcom4::{MethodInfo, cohesion_components};
285///
286/// let methods = vec![
287///     MethodInfo::new("parse", BTreeSet::from(["input".into()]), BTreeSet::new()),
288///     MethodInfo::new("render", BTreeSet::from(["output".into()]), BTreeSet::new()),
289/// ];
290///
291/// assert_eq!(cohesion_components(&methods), 2);
292/// ```
293#[must_use]
294pub fn cohesion_components(methods: &[MethodInfo]) -> usize {
295    if methods.is_empty() {
296        return 0;
297    }
298
299    let mut uf = UnionFind::new(methods.len());
300    union_by_shared_fields(methods, &mut uf);
301    union_by_method_calls(methods, &mut uf);
302    uf.component_count()
303}
304
305pub mod extract;
306
307pub use extract::{MethodInfoBuilder, collect_method_infos};
308
309#[cfg(test)]
310mod tests;