Skip to main content

whitaker_common/lcom4/
extract.rs

1//! Method metadata extraction for LCOM4 cohesion analysis.
2//!
3//! Provides [`MethodInfoBuilder`], a builder that incrementally accumulates
4//! field accesses and method calls for a single method while filtering out
5//! macro-expanded spans. Lint drivers walk method bodies and call
6//! [`MethodInfoBuilder::record_field_access`] and
7//! [`MethodInfoBuilder::record_method_call`] for each relevant HIR node.
8//! The builder produces a [`MethodInfo`] suitable for
9//! [`cohesion_components`](super::cohesion_components).
10//!
11//! This module is a pure library with no `rustc_private` dependency. Macro-span
12//! filtering is expressed through an `is_from_expansion: bool` parameter that
13//! the caller (the HIR walker) populates from `expr.span.from_expansion()`. This
14//! mirrors the pattern used by `bumpy_road_function`'s `SegmentBuilder`, where
15//! macro-span checks happen in the HIR walker before feeding data to the pure
16//! signal builder in `common`.
17
18use std::collections::BTreeSet;
19
20use super::MethodInfo;
21
22/// Incrementally builds a [`MethodInfo`] from field accesses and method calls
23/// observed during a method body walk.
24///
25/// Entries where `is_from_expansion` is `true` are silently discarded, preventing
26/// macro-generated code from inflating the cohesion graph.
27///
28/// # Examples
29///
30/// ```
31/// use whitaker_common::lcom4::MethodInfoBuilder;
32///
33/// let mut builder = MethodInfoBuilder::new("process");
34/// builder.record_field_access("data", false);
35/// builder.record_field_access("generated_field", true); // macro — filtered
36/// builder.record_method_call("validate", false);
37///
38/// let info = builder.build();
39/// assert!(info.accessed_fields().contains("data"));
40/// assert!(!info.accessed_fields().contains("generated_field"));
41/// assert!(info.called_methods().contains("validate"));
42/// ```
43#[derive(Clone, Debug)]
44pub struct MethodInfoBuilder {
45    name: String,
46    accessed_fields: BTreeSet<String>,
47    called_methods: BTreeSet<String>,
48}
49
50impl MethodInfoBuilder {
51    /// Creates a new builder for the named method with empty field and call
52    /// sets.
53    ///
54    /// # Examples
55    ///
56    /// ```
57    /// use whitaker_common::lcom4::MethodInfoBuilder;
58    ///
59    /// let builder = MethodInfoBuilder::new("read");
60    /// assert!(builder.is_empty());
61    /// ```
62    #[must_use]
63    pub fn new(name: impl Into<String>) -> Self {
64        Self {
65            name: name.into(),
66            accessed_fields: BTreeSet::new(),
67            called_methods: BTreeSet::new(),
68        }
69    }
70
71    /// Records a field access observed in the method body.
72    ///
73    /// When `is_from_expansion` is `true` the access is silently ignored,
74    /// preventing macro-generated field references from inflating the
75    /// cohesion graph. Duplicate field names are naturally deduplicated
76    /// by the underlying `BTreeSet`.
77    ///
78    /// # Examples
79    ///
80    /// ```
81    /// use whitaker_common::lcom4::MethodInfoBuilder;
82    ///
83    /// let mut builder = MethodInfoBuilder::new("render");
84    /// builder.record_field_access("canvas", false);
85    /// builder.record_field_access("macro_field", true);
86    ///
87    /// let info = builder.build();
88    /// assert!(info.accessed_fields().contains("canvas"));
89    /// assert!(!info.accessed_fields().contains("macro_field"));
90    /// ```
91    pub fn record_field_access(&mut self, field_name: &str, is_from_expansion: bool) {
92        if !is_from_expansion {
93            self.accessed_fields.insert(field_name.to_owned());
94        }
95    }
96
97    /// Records a method call on the same type observed in the method body.
98    ///
99    /// When `is_from_expansion` is `true` the call is silently ignored,
100    /// preventing macro-generated calls from inflating the cohesion graph.
101    /// Duplicate method names are naturally deduplicated by the underlying
102    /// `BTreeSet`.
103    ///
104    /// # Examples
105    ///
106    /// ```
107    /// use whitaker_common::lcom4::MethodInfoBuilder;
108    ///
109    /// let mut builder = MethodInfoBuilder::new("dispatch");
110    /// builder.record_method_call("validate", false);
111    /// builder.record_method_call("macro_helper", true);
112    ///
113    /// let info = builder.build();
114    /// assert!(info.called_methods().contains("validate"));
115    /// assert!(!info.called_methods().contains("macro_helper"));
116    /// ```
117    pub fn record_method_call(&mut self, method_name: &str, is_from_expansion: bool) {
118        if !is_from_expansion {
119            self.called_methods.insert(method_name.to_owned());
120        }
121    }
122
123    /// Returns `true` when no (non-filtered) field accesses or method calls
124    /// have been recorded.
125    ///
126    /// This is useful for lint drivers that wish to skip methods with no
127    /// observable state interaction before building the full `MethodInfo`.
128    ///
129    /// # Examples
130    ///
131    /// ```
132    /// use whitaker_common::lcom4::MethodInfoBuilder;
133    ///
134    /// let mut builder = MethodInfoBuilder::new("noop");
135    /// assert!(builder.is_empty());
136    ///
137    /// builder.record_field_access("x", false);
138    /// assert!(!builder.is_empty());
139    /// ```
140    #[must_use]
141    pub fn is_empty(&self) -> bool {
142        self.accessed_fields.is_empty() && self.called_methods.is_empty()
143    }
144
145    /// Consumes the builder and returns the completed [`MethodInfo`].
146    ///
147    /// # Examples
148    ///
149    /// ```
150    /// use whitaker_common::lcom4::MethodInfoBuilder;
151    ///
152    /// let mut builder = MethodInfoBuilder::new("process");
153    /// builder.record_field_access("data", false);
154    /// let info = builder.build();
155    ///
156    /// assert_eq!(info.name(), "process");
157    /// assert!(info.accessed_fields().contains("data"));
158    /// ```
159    #[must_use]
160    pub fn build(self) -> MethodInfo {
161        MethodInfo::new(self.name, self.accessed_fields, self.called_methods)
162    }
163}
164
165/// Builds a [`Vec<MethodInfo>`] from an iterator of completed builders.
166///
167/// This is a convenience for lint drivers that collect one builder per method
168/// during traversal and then need the final `MethodInfo` slice for
169/// [`cohesion_components`](super::cohesion_components).
170///
171/// # Examples
172///
173/// ```
174/// use whitaker_common::lcom4::{MethodInfoBuilder, collect_method_infos};
175///
176/// let mut b1 = MethodInfoBuilder::new("read");
177/// b1.record_field_access("buf", false);
178/// let mut b2 = MethodInfoBuilder::new("write");
179/// b2.record_field_access("buf", false);
180///
181/// let infos = collect_method_infos(vec![b1, b2]);
182/// assert_eq!(infos.len(), 2);
183/// assert_eq!(infos[0].name(), "read");
184/// assert_eq!(infos[1].name(), "write");
185/// ```
186#[must_use]
187pub fn collect_method_infos(
188    builders: impl IntoIterator<Item = MethodInfoBuilder>,
189) -> Vec<MethodInfo> {
190    builders.into_iter().map(MethodInfoBuilder::build).collect()
191}
192
193#[cfg(test)]
194mod tests {
195    //! rstest-based unit tests for [`super::MethodInfoBuilder`] and
196    //! [`super::collect_method_infos`].
197
198    use super::*;
199    use rstest::rstest;
200
201    /// Applies field and call records to a builder and asserts against
202    /// expected sets.
203    fn assert_extraction(
204        field_records: &[(&str, bool)],
205        call_records: &[(&str, bool)],
206        expected_fields: &[&str],
207        expected_calls: &[&str],
208    ) {
209        let mut builder = MethodInfoBuilder::new("test_method");
210        for &(name, from_exp) in field_records {
211            builder.record_field_access(name, from_exp);
212        }
213        for &(name, from_exp) in call_records {
214            builder.record_method_call(name, from_exp);
215        }
216        let info = builder.build();
217
218        for &field in expected_fields {
219            assert!(
220                info.accessed_fields().contains(field),
221                "expected field '{field}' to be present"
222            );
223        }
224        for &method in expected_calls {
225            assert!(
226                info.called_methods().contains(method),
227                "expected method '{method}' to be present"
228            );
229        }
230        assert_eq!(info.accessed_fields().len(), expected_fields.len());
231        assert_eq!(info.called_methods().len(), expected_calls.len());
232    }
233
234    // --- Happy paths ---
235
236    #[rstest]
237    fn single_field_access_is_recorded() {
238        assert_extraction(&[("data", false)], &[], &["data"], &[]);
239    }
240
241    #[rstest]
242    fn single_method_call_is_recorded() {
243        assert_extraction(&[], &[("validate", false)], &[], &["validate"]);
244    }
245
246    #[rstest]
247    fn multiple_fields_and_calls_accumulate() {
248        assert_extraction(
249            &[("alpha", false), ("beta", false)],
250            &[("do_work", false), ("validate", false)],
251            &["alpha", "beta"],
252            &["do_work", "validate"],
253        );
254    }
255
256    #[rstest]
257    fn duplicate_field_names_are_deduplicated() {
258        assert_extraction(&[("x", false), ("x", false)], &[], &["x"], &[]);
259    }
260
261    #[rstest]
262    fn duplicate_method_names_are_deduplicated() {
263        assert_extraction(&[], &[("run", false), ("run", false)], &[], &["run"]);
264    }
265
266    // --- Macro-span filtering ---
267
268    #[rstest]
269    fn field_from_expansion_is_filtered() {
270        assert_extraction(&[("generated", true)], &[], &[], &[]);
271    }
272
273    #[rstest]
274    fn method_from_expansion_is_filtered() {
275        assert_extraction(&[], &[("macro_helper", true)], &[], &[]);
276    }
277
278    #[rstest]
279    fn mixed_expansion_and_regular_entries() {
280        assert_extraction(
281            &[("real", false), ("generated", true)],
282            &[("helper", false), ("macro_call", true)],
283            &["real"],
284            &["helper"],
285        );
286    }
287
288    #[rstest]
289    fn all_from_expansion_yields_empty_sets() {
290        assert_extraction(&[("a", true), ("b", true)], &[("c", true)], &[], &[]);
291    }
292
293    // --- Edge cases ---
294
295    #[rstest]
296    fn builder_name_preserved() {
297        let builder = MethodInfoBuilder::new("my_method");
298        let info = builder.build();
299        assert_eq!(info.name(), "my_method");
300    }
301
302    #[rstest]
303    fn empty_builder_yields_empty_method_info() {
304        let builder = MethodInfoBuilder::new("empty");
305        let info = builder.build();
306        assert!(info.accessed_fields().is_empty());
307        assert!(info.called_methods().is_empty());
308    }
309
310    #[rstest]
311    fn is_empty_true_when_no_records() {
312        let builder = MethodInfoBuilder::new("noop");
313        assert!(builder.is_empty());
314    }
315
316    #[rstest]
317    fn is_empty_false_after_field_record() {
318        let mut builder = MethodInfoBuilder::new("reader");
319        builder.record_field_access("buf", false);
320        assert!(!builder.is_empty());
321    }
322
323    #[rstest]
324    fn is_empty_false_after_method_record() {
325        let mut builder = MethodInfoBuilder::new("caller");
326        builder.record_method_call("helper", false);
327        assert!(!builder.is_empty());
328    }
329
330    #[rstest]
331    fn is_empty_true_after_only_expansion_records() {
332        let mut builder = MethodInfoBuilder::new("generated");
333        builder.record_field_access("a", true);
334        builder.record_method_call("b", true);
335        assert!(builder.is_empty());
336    }
337
338    // --- collect_method_infos ---
339
340    #[rstest]
341    fn collect_empty_iterator() {
342        let infos = collect_method_infos(Vec::new());
343        assert!(infos.is_empty());
344    }
345
346    #[rstest]
347    fn collect_preserves_order() {
348        let mut b1 = MethodInfoBuilder::new("alpha");
349        b1.record_field_access("x", false);
350        let mut b2 = MethodInfoBuilder::new("beta");
351        b2.record_method_call("alpha", false);
352        let b3 = MethodInfoBuilder::new("gamma");
353
354        let infos = collect_method_infos(vec![b1, b2, b3]);
355        assert_eq!(infos.len(), 3);
356        assert_eq!(infos[0].name(), "alpha");
357        assert_eq!(infos[1].name(), "beta");
358        assert_eq!(infos[2].name(), "gamma");
359        assert!(infos[0].accessed_fields().contains("x"));
360        assert!(infos[1].called_methods().contains("alpha"));
361    }
362}