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}