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