Skip to main content

big_code_analysis/
count.rs

1// Metric counts (token, function, branch, argument, etc.) are stored as
2// `usize` and crossed with `f64` averages, ratios, and Halstead scores
3// across the cyclomatic / MI / Halstead computations. The `usize as f64`
4// and `f64 as usize` casts are intentional and snapshot-anchored — every
5// site is bounded by the count it came from. Allowing the lints at the
6// module level keeps the metric arithmetic legible.
7#![allow(
8    clippy::cast_precision_loss,
9    clippy::cast_possible_truncation,
10    clippy::cast_sign_loss
11)]
12// Per-language metric and AST modules deliberately consume the macro-
13// generated tree-sitter token enums via `use crate::*` and `use Foo::*`
14// inside match expressions — explicit imports would list dozens of
15// variants per arm and obscure the per-language token sets that are the
16// point of these files. Allowed at the module level rather than per
17// function so the per-language impl blocks stay readable.
18#![allow(clippy::wildcard_imports, clippy::enum_glob_use)]
19
20use num_format::{Locale, ToFormattedString};
21use std::fmt;
22use std::sync::{Arc, Mutex};
23
24use crate::traits::ParserTrait;
25
26/// Counts the types of nodes specified in the input slice and the
27/// number of nodes in a code. Crate-internal walk core reached through
28/// the [`crate::Ast::count`] seam.
29pub(crate) fn count<T: ParserTrait>(parser: &T, filters: &[String]) -> (usize, usize) {
30    let filters = parser.filters(filters);
31    let node = parser.root();
32    let mut cursor = node.cursor();
33    let mut stack = Vec::new();
34    let mut good = 0;
35    let mut total = 0;
36
37    stack.push(node);
38
39    while let Some(node) = stack.pop() {
40        total += 1;
41        if filters.any(&node) {
42            good += 1;
43        }
44        cursor.reset(&node);
45        if cursor.goto_first_child() {
46            loop {
47                stack.push(cursor.node());
48                if !cursor.goto_next_sibling() {
49                    break;
50                }
51            }
52        }
53    }
54    (good, total)
55}
56
57/// Opaque, shareable collector that accumulates a [`Count`] across the
58/// worker threads of a [`crate::ConcurrentRunner`] walk.
59///
60/// Wraps the shared `Arc<Mutex<Count>>` behind a newtype so callers do
61/// not handle the synchronization machinery directly. [`Clone`] is a
62/// cheap reference-count bump, so each worker
63/// can hold its own handle to the same tally while the config still
64/// satisfies the `'static + Send + Sync` bound of
65/// [`crate::ConcurrentRunner`]. Recover the final tally with
66/// [`CountCollector::into_count`] once every worker has joined.
67#[derive(Debug, Clone)]
68pub struct CountCollector(Arc<Mutex<Count>>);
69
70impl CountCollector {
71    /// Creates an empty collector.
72    #[must_use]
73    pub fn new() -> Self {
74        Self(Arc::new(Mutex::new(Count::default())))
75    }
76
77    /// Creates a collector seeded with an existing tally.
78    #[must_use]
79    pub fn with_count(count: Count) -> Self {
80        Self(Arc::new(Mutex::new(count)))
81    }
82
83    /// Add a per-file `(good, total)` tally into the shared collector.
84    ///
85    /// The aggregation is two monotonically-incremented counters, so a
86    /// peer worker that panicked mid-update leaves at worst a slightly
87    /// low tally — never an unsafe state. Recover the poisoned guard
88    /// (issue #445) and clear the poison so this and later callers — and
89    /// the collector's final [`CountCollector::into_count`] — degrade
90    /// rather than cascade into a pool-wide abort the way `.unwrap()`
91    /// would.
92    pub fn add(&self, good: usize, total: usize) {
93        let mut results = self.0.lock().unwrap_or_else(|poisoned| {
94            self.0.clear_poison();
95            poisoned.into_inner()
96        });
97        results.good += good;
98        results.total += total;
99    }
100
101    /// Consumes the collector, returning the accumulated [`Count`].
102    ///
103    /// Call this only after every worker sharing a clone of this
104    /// collector has joined, so the underlying `Arc` reference count is
105    /// back to one. Degrades rather than panics in the unlikely event
106    /// that a worker panicked mid-update and poisoned the inner mutex
107    /// (issue #445): the recovered guard still holds the fully-applied
108    /// tally because the aggregation is two monotonically-incremented
109    /// counters.
110    ///
111    /// If the `Arc` is unexpectedly **still shared**, a peer clone
112    /// survived past this call — a worker failed to join — which is a
113    /// caller-side coordination bug, not a recoverable runtime state.
114    /// Another clone may still call [`CountCollector::add`] afterwards,
115    /// so the value returned here is a **best-effort snapshot of a tally
116    /// that is not yet final**, not the complete aggregate the
117    /// `#[must_use]` return implies (issue #757). A `debug_assert!`
118    /// trips loudly on this path so the coordination bug surfaces in
119    /// debug and test builds; release builds still degrade to the
120    /// snapshot rather than panicking, honoring the project's
121    /// no-panic-in-production contract.
122    #[must_use]
123    pub fn into_count(self) -> Count {
124        match Arc::try_unwrap(self.0) {
125            Ok(mutex) => mutex
126                .into_inner()
127                .unwrap_or_else(std::sync::PoisonError::into_inner),
128            Err(shared) => {
129                // A still-shared `Arc` means a worker has not joined: the
130                // returned tally is a non-final snapshot (issue #757).
131                // Trip loudly in debug/test builds to expose the
132                // coordination bug while release degrades gracefully.
133                debug_assert!(
134                    false,
135                    "CountCollector::into_count called while the collector \
136                     is still shared (a worker failed to join); the \
137                     returned Count is a non-final snapshot"
138                );
139                let guard = shared
140                    .lock()
141                    .unwrap_or_else(std::sync::PoisonError::into_inner);
142                Count {
143                    good: guard.good,
144                    total: guard.total,
145                }
146            }
147        }
148    }
149}
150
151impl Default for CountCollector {
152    fn default() -> Self {
153        Self::new()
154    }
155}
156
157/// Count of different types of nodes in a code.
158#[derive(Debug, Default)]
159pub struct Count {
160    /// The number of specific types of nodes searched in a code
161    pub good: usize,
162    /// The total number of nodes in a code
163    pub total: usize,
164}
165
166impl fmt::Display for Count {
167    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
168        writeln!(
169            f,
170            "Total nodes: {}",
171            self.total.to_formatted_string(&Locale::en)
172        )?;
173        writeln!(
174            f,
175            "Found nodes: {}",
176            self.good.to_formatted_string(&Locale::en)
177        )?;
178        // Guard the empty case: a zero-match `bca count` leaves the default
179        // `Count { good: 0, total: 0 }`, and `0.0 / 0.0` is `NaN`, which would
180        // render as the meaningless "Percentage: NaN%". Report 0% instead.
181        let percentage = if self.total == 0 {
182            0.0
183        } else {
184            (self.good as f64) / (self.total as f64) * 100.
185        };
186        write!(f, "Percentage: {percentage:.2}%")
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use std::thread;
194
195    // Regression test for issue #445: a poisoned `stats` mutex must not
196    // cascade into a pool-wide panic. A worker that panics while holding
197    // the shared guard poisons the lock; `CountCollector::add` used to
198    // re-panic on `.lock().unwrap()`. Verified by revert per
199    // `.claude/rules/testing.md`: reverting the recovery makes this test
200    // panic instead of applying the tally.
201    #[test]
202    fn add_degrades_on_poisoned_stats_mutex() {
203        let stats = Arc::new(Mutex::new(Count::default()));
204
205        // Poison the mutex: panic while holding the guard on a helper
206        // thread, mirroring the dispatch_preproc #425 regression test.
207        let poisoner = stats.clone();
208        let handle = thread::spawn(move || {
209            let _guard = poisoner.lock().expect("fresh mutex is unpoisoned");
210            panic!("intentional panic to poison the stats mutex");
211        });
212        assert!(
213            handle.join().is_err(),
214            "poisoner thread should have panicked"
215        );
216        assert!(stats.is_poisoned(), "test setup failed to poison the mutex");
217
218        // Adding into a poisoned collector must degrade (recover the
219        // guard, clear the poison) rather than panic on `.lock()`.
220        let collector = CountCollector(stats.clone());
221        collector.add(2, 5);
222
223        // The recovery clears the poison so later peers and the
224        // collector's final `into_count()` see a usable, fully-applied
225        // tally.
226        assert!(
227            !stats.is_poisoned(),
228            "recovery should clear the poison flag"
229        );
230        let recovered = stats.lock().expect("poison cleared, lock must succeed");
231        assert_eq!(
232            (recovered.good, recovered.total),
233            (2, 5),
234            "the surviving worker's counts must still be applied"
235        );
236    }
237
238    // `into_count` must surface the tally accumulated by every worker
239    // sharing a clone of the collector, after they have all joined.
240    #[test]
241    fn into_count_returns_accumulated_tally() {
242        let collector = CountCollector::new();
243
244        let mut handles = Vec::new();
245        for _ in 0..4 {
246            let worker = collector.clone();
247            handles.push(thread::spawn(move || {
248                let mut guard = worker.0.lock().expect("fresh mutex is unpoisoned");
249                guard.good += 1;
250                guard.total += 10;
251            }));
252        }
253        for handle in handles {
254            handle.join().expect("worker thread must not panic");
255        }
256
257        let count = collector.into_count();
258        assert_eq!(count.good, 4, "every worker's good count must be summed");
259        assert_eq!(count.total, 40, "every worker's total count must be summed");
260    }
261
262    // `into_count` degrades to the recovered tally when the inner mutex
263    // is poisoned, mirroring the #445 invariant for the extraction side.
264    #[test]
265    fn into_count_degrades_on_poisoned_mutex() {
266        let collector = CountCollector::with_count(Count { good: 3, total: 7 });
267
268        let poisoner = collector.clone();
269        let handle = thread::spawn(move || {
270            let _guard = poisoner.0.lock().expect("fresh mutex is unpoisoned");
271            panic!("intentional panic to poison the collector mutex");
272        });
273        assert!(
274            handle.join().is_err(),
275            "poisoner thread should have panicked"
276        );
277
278        let count = collector.into_count();
279        assert_eq!(count.good, 3, "poison recovery must preserve the tally");
280        assert_eq!(count.total, 7, "poison recovery must preserve the tally");
281    }
282
283    // Regression test for issue #757: calling `into_count` while a peer
284    // clone is still alive (a worker failed to join) is a coordination
285    // bug that used to return a non-final snapshot silently. The
286    // `debug_assert!` in the `Err(shared)` arm must trip loudly so the
287    // misuse cannot masquerade as a final aggregate. Gated on
288    // `debug_assertions`: `debug_assert!` is a no-op under `--release`,
289    // where the call degrades to the snapshot instead of panicking.
290    // Verified by revert per `.claude/rules/testing.md`: without the
291    // `debug_assert!`, `into_count` returns normally and this test fails
292    // (no panic), proving the assert is what makes the misuse loud.
293    #[test]
294    #[cfg(debug_assertions)]
295    #[should_panic(expected = "still shared")]
296    fn into_count_panics_in_debug_when_still_shared() {
297        let collector = CountCollector::with_count(Count { good: 1, total: 2 });
298        // Hold a live clone so the `Arc` strong count stays above one,
299        // forcing `Arc::try_unwrap` down the still-shared `Err` arm.
300        let _surviving_peer = collector.clone();
301        let _ = collector.into_count();
302    }
303
304    // Regression test for issue #709: the default `Count { good: 0, total: 0 }`
305    // (a zero-match `bca count` run) used to render "Percentage: NaN%" because
306    // `0.0 / 0.0` is NaN. The empty case must report 0.00% instead.
307    #[test]
308    fn display_reports_zero_percent_for_empty_count() {
309        let rendered = Count::default().to_string();
310        assert!(
311            rendered.contains("Percentage: 0.00%"),
312            "empty Count must render 0.00%, got: {rendered}"
313        );
314        assert!(
315            !rendered.contains("NaN"),
316            "empty Count must not render NaN, got: {rendered}"
317        );
318    }
319
320    // A non-empty Count still renders the true ratio (3/7 ≈ 42.86%), so the
321    // zero-guard does not accidentally clamp populated tallies to 0%.
322    #[test]
323    fn display_reports_true_percentage_for_nonempty_count() {
324        let rendered = Count { good: 3, total: 7 }.to_string();
325        assert!(
326            rendered.contains("Percentage: 42.86%"),
327            "3/7 must render 42.86%, got: {rendered}"
328        );
329    }
330}