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;