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