big_code_analysis/metric_set.rs
1//! Per-metric selection: the [`Metric`] enum and the
2//! [`MetricSet`] bitfield it gates.
3//!
4//! Used by [`MetricsOptions::with_only`](crate::MetricsOptions::with_only)
5//! to restrict which metrics are computed during a walk, and by
6//! [`CodeMetrics`](crate::CodeMetrics)'s `Serialize` impl to elide
7//! fields the caller did not select.
8
9use std::fmt;
10use std::str::FromStr;
11
12/// One metric computed by the analysis walker.
13///
14/// Pass a slice of these to
15/// [`MetricsOptions::with_only`](crate::MetricsOptions::with_only) to
16/// restrict computation to the listed metrics.
17///
18/// `#[non_exhaustive]` so future metrics can land additively. Use
19/// `match` against the existing variants and either a wildcard arm or
20/// the `m if !MetricSet::all().contains(m)` guard to stay
21/// forwards-compatible.
22///
23/// `Ord` follows declaration order, not the [`Display`](fmt::Display)
24/// spelling. It exists so [`Metric`] can key a `BTreeSet` — notably the
25/// suppression scope (`SuppressionScope::Some`) — with a deterministic,
26/// stable iteration order across runs. Do not rely on the ordering being
27/// alphabetical; reorder the variants only with a deliberate review of
28/// every serialized `BTreeSet<Metric>` snapshot.
29#[non_exhaustive]
30#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)]
31pub enum Metric {
32 /// Cognitive complexity ([`crate::cognitive::Stats`]).
33 Cognitive,
34 /// Cyclomatic complexity ([`crate::cyclomatic::Stats`]).
35 Cyclomatic,
36 /// Halstead ([`crate::halstead::Stats`]).
37 Halstead,
38 /// LoC family ([`crate::loc::Stats`]).
39 Loc,
40 /// Number of methods ([`crate::nom::Stats`]).
41 Nom,
42 /// Token counts ([`crate::tokens::Stats`]).
43 Tokens,
44 /// Number of arguments ([`crate::nargs::Stats`]).
45 Nargs,
46 /// Exit-point count ([`crate::nexits::Stats`]).
47 Nexits,
48 /// ABC ([`crate::abc::Stats`]).
49 Abc,
50 /// Number of public methods ([`crate::npm::Stats`]).
51 Npm,
52 /// Number of public attributes ([`crate::npa::Stats`]).
53 Npa,
54 /// Maintainability index ([`crate::mi::Stats`]). Derived metric:
55 /// selecting only `Mi` via
56 /// [`MetricsOptions::with_only`](crate::MetricsOptions::with_only)
57 /// also pulls in [`Metric::Loc`], [`Metric::Cyclomatic`], and
58 /// [`Metric::Halstead`].
59 Mi,
60 /// Weighted methods per class ([`crate::wmc::Stats`]). Derived
61 /// metric: selecting `Wmc` also pulls in [`Metric::Cyclomatic`]
62 /// and [`Metric::Nom`].
63 Wmc,
64}
65
66impl Metric {
67 // Bit position used inside [`MetricSet`]. The ordering is
68 // intentionally arbitrary — the only contract is that each
69 // variant maps to a distinct bit.
70 //
71 // Returns `u32` to match [`MetricSet`]'s storage width: at `u16`
72 // the bitfield would overflow once a 17th variant landed (debug
73 // panic / release wrap), and `Metric` is `#[non_exhaustive]`
74 // specifically so new variants can land additively.
75 #[inline]
76 const fn bit(self) -> u32 {
77 1 << (self as u32)
78 }
79
80 /// Returns the slice of metrics this metric depends on.
81 ///
82 /// Derived and averaged metrics consume the outputs of other
83 /// metrics during the finalize step; selecting one without its
84 /// dependencies would leave the dependency's `Stats` at default
85 /// (zero) values and silently corrupt the result. Callers
86 /// typically reach this through
87 /// [`MetricsOptions::with_only`](crate::MetricsOptions::with_only),
88 /// which auto-resolves the closure transparently.
89 #[must_use]
90 pub const fn dependencies(self) -> &'static [Metric] {
91 match self {
92 // Mi = function(Loc, Cyclomatic, Halstead). All three must
93 // be computed for the MI formula to be meaningful.
94 Self::Mi => &[Self::Loc, Self::Cyclomatic, Self::Halstead],
95 // Wmc aggregates per-method cyclomatic complexity and
96 // needs Nom to count those methods.
97 Self::Wmc => &[Self::Cyclomatic, Self::Nom],
98 // Cognitive, Nexits, and Nargs each expose a per-function
99 // average whose divisor is the function/closure count
100 // sourced from Nom (see `spaces::compute_averages`).
101 // Without Nom the divisor would be the `Stats` default
102 // (zero), producing inf/NaN averages (#428).
103 Self::Cognitive | Self::Nexits | Self::Nargs => &[Self::Nom],
104 _ => &[],
105 }
106 }
107
108 /// Canonical user-facing name for each metric — the single
109 /// source of truth shared by the Python bindings'
110 /// `bca.METRIC_NAMES` constant, the `unknown metric: <bad>;
111 /// valid: …` error message, and any downstream Rust consumer
112 /// that parses user input into a [`MetricSet`].
113 ///
114 /// Each entry round-trips through [`Metric::from_str`]. Every
115 /// metric uses one canonical spelling end-to-end:
116 /// [`Metric::Nexits`] is `"nexits"` in `Display`, in this table,
117 /// and as the JSON output key (the `CodeMetrics::Serialize` impl
118 /// in `src/spaces.rs`).
119 ///
120 /// Alphabetised. The drift between this table and the
121 /// `FromStr` arms (or the `Metric` enum itself) is guarded by
122 /// `names_table_parses_to_every_variant` and
123 /// `names_table_is_alphabetised` in the test module below.
124 pub const NAMES: &'static [&'static str] = &[
125 "abc",
126 "cognitive",
127 "cyclomatic",
128 "halstead",
129 "loc",
130 "mi",
131 "nargs",
132 "nexits",
133 "nom",
134 "npa",
135 "npm",
136 "tokens",
137 "wmc",
138 ];
139
140 /// Every metric except [`Metric::Tokens`], in declaration order.
141 ///
142 /// `tokens` is the one metric that cannot be named in a suppression
143 /// marker (`bca: suppress(tokens)` is rejected). It *is* a
144 /// configurable threshold — `bca check --threshold tokens=N` gates
145 /// on it — so the exclusion is about suppressibility alone. Reading
146 /// it as "no threshold either" is the trap #1113 had to work
147 /// around: deriving a metric selection from
148 /// [`crate::threshold_metric_for_name`], which returns `None` here,
149 /// silently leaves `tokens` uncomputed and disarms that gate.
150 ///
151 /// This is the single source
152 /// of truth for the suppressible vocabulary: the suppression
153 /// parser's "known metrics" hint and the threshold-name resolver
154 /// both derive from it rather than hardcoding the list.
155 pub fn suppressible() -> impl Iterator<Item = Metric> {
156 Self::ALL.iter().copied().filter(|m| *m != Self::Tokens)
157 }
158
159 /// Every [`Metric`] variant, in declaration order. Drives
160 /// [`Metric::suppressible`] and any consumer that needs to iterate
161 /// the full set without re-deriving it from `NAMES`.
162 ///
163 /// `pub(crate)` so in-crate tests that must cover *every* metric —
164 /// notably `metric_selection_parity` in `src/spaces_tests.rs` —
165 /// enumerate this list rather than a hand-copied one that a new
166 /// variant would silently escape.
167 pub(crate) const ALL: &'static [Self] = &[
168 Self::Cognitive,
169 Self::Cyclomatic,
170 Self::Halstead,
171 Self::Loc,
172 Self::Nom,
173 Self::Tokens,
174 Self::Nargs,
175 Self::Nexits,
176 Self::Abc,
177 Self::Npm,
178 Self::Npa,
179 Self::Mi,
180 Self::Wmc,
181 ];
182}
183
184impl fmt::Display for Metric {
185 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186 let s = match self {
187 Self::Cognitive => "cognitive",
188 Self::Cyclomatic => "cyclomatic",
189 Self::Halstead => "halstead",
190 Self::Loc => "loc",
191 Self::Nom => "nom",
192 Self::Tokens => "tokens",
193 Self::Nargs => "nargs",
194 Self::Nexits => "nexits",
195 Self::Abc => "abc",
196 Self::Npm => "npm",
197 Self::Npa => "npa",
198 Self::Mi => "mi",
199 Self::Wmc => "wmc",
200 };
201 f.write_str(s)
202 }
203}
204
205/// Error returned by [`Metric::from_str`] when the input
206/// is not a recognised metric name.
207///
208/// Holds the offending input verbatim. Downstream consumers that own
209/// the canonical name table (e.g. the `bca` Python bindings'
210/// `METRIC_NAMES` constant) typically compose this with a
211/// `valid: <list>` suffix from their own source of truth; this type
212/// deliberately stays out of that policy and only carries the
213/// rejected input so the wrapper layer can format the user-facing
214/// message however it wants.
215#[derive(Debug, Clone, PartialEq, Eq)]
216pub struct ParseMetricError(String);
217
218impl ParseMetricError {
219 /// The rejected input that failed to parse as a [`Metric`] name.
220 ///
221 /// Lets callers recover the offending string programmatically
222 /// rather than scraping it out of the [`Display`](fmt::Display)
223 /// output.
224 #[must_use]
225 pub fn input(&self) -> &str {
226 &self.0
227 }
228}
229
230impl fmt::Display for ParseMetricError {
231 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232 write!(f, "unknown metric: {}", self.0)
233 }
234}
235
236impl std::error::Error for ParseMetricError {}
237
238impl FromStr for Metric {
239 type Err = ParseMetricError;
240
241 /// Parse a [`Metric`] from its [`fmt::Display`] spelling.
242 ///
243 /// Strict lowercase: `"Loc"` is rejected. Every metric has exactly
244 /// one accepted spelling, matching its `Display` form, `NAMES`
245 /// entry, and JSON output key.
246 fn from_str(s: &str) -> Result<Self, Self::Err> {
247 match s {
248 "cognitive" => Ok(Self::Cognitive),
249 "cyclomatic" => Ok(Self::Cyclomatic),
250 "halstead" => Ok(Self::Halstead),
251 "loc" => Ok(Self::Loc),
252 "nom" => Ok(Self::Nom),
253 "tokens" => Ok(Self::Tokens),
254 "nargs" => Ok(Self::Nargs),
255 "nexits" => Ok(Self::Nexits),
256 "abc" => Ok(Self::Abc),
257 "npm" => Ok(Self::Npm),
258 "npa" => Ok(Self::Npa),
259 "mi" => Ok(Self::Mi),
260 "wmc" => Ok(Self::Wmc),
261 _ => Err(ParseMetricError(s.to_owned())),
262 }
263 }
264}
265
266// Serialize/Deserialize are hand-written rather than derived so the wire
267// form is the canonical [`Display`] spelling (`nargs`, `nexits`,
268// `tokens`, …) — the same vocabulary used in JSON output keys, error
269// messages, and `Metric::NAMES`. A `#[derive(Serialize)]` with
270// `rename_all = "snake_case"` would emit `n_args` / `n_exits` instead,
271// diverging from every other surface. Routing through `Display`/`FromStr`
272// keeps the spelling single-sourced. `Metric` reaches the wire as the
273// element type of `SuppressionScope::Some`'s `BTreeSet`.
274impl serde::Serialize for Metric {
275 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
276 serializer.serialize_str(&self.to_string())
277 }
278}
279
280impl<'de> serde::Deserialize<'de> for Metric {
281 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
282 // `Cow<str>` borrows from self-describing, zero-copy formats
283 // (JSON without escapes) and owns from reader-based or
284 // non-borrowing ones (CBOR, YAML, TOML, JSON with escapes).
285 // `<&str>` would reject every non-borrowing format, which is
286 // exactly how `BTreeSet<Metric>` reaches the wire inside a
287 // `SuppressionScope::Some` CBOR/YAML/TOML payload.
288 let s = std::borrow::Cow::<str>::deserialize(deserializer)?;
289 s.parse().map_err(serde::de::Error::custom)
290 }
291}
292
293/// Bitfield of selected metrics.
294///
295/// Stored on [`MetricsOptions`](crate::MetricsOptions) (controls
296/// which metrics the walker computes) and on
297/// [`CodeMetrics`](crate::CodeMetrics) (controls which fields the
298/// `Serialize` impl emits).
299///
300/// `MetricSet::all()` is the default: every metric enabled, matching
301/// the pre-#257 behaviour.
302#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
303pub struct MetricSet(u32);
304
305impl MetricSet {
306 // All-metrics mask: OR together every variant's bit. Kept
307 // explicit (rather than `(1 << N) - 1`) so adding a new variant
308 // requires a deliberate edit here and surfaces in code review.
309 const ALL_BITS: u32 = Metric::Cognitive.bit()
310 | Metric::Cyclomatic.bit()
311 | Metric::Halstead.bit()
312 | Metric::Loc.bit()
313 | Metric::Nom.bit()
314 | Metric::Tokens.bit()
315 | Metric::Nargs.bit()
316 | Metric::Nexits.bit()
317 | Metric::Abc.bit()
318 | Metric::Npm.bit()
319 | Metric::Npa.bit()
320 | Metric::Mi.bit()
321 | Metric::Wmc.bit();
322
323 /// Empty set (no metrics selected).
324 #[inline]
325 #[must_use]
326 pub const fn empty() -> Self {
327 Self(0)
328 }
329
330 /// Full set (every metric selected). This is the default for
331 /// [`MetricsOptions`](crate::MetricsOptions), preserving the
332 /// pre-#257 "compute everything" behaviour.
333 #[inline]
334 #[must_use]
335 pub const fn all() -> Self {
336 Self(Self::ALL_BITS)
337 }
338
339 /// Returns `true` if `metric` is in the set.
340 #[inline]
341 #[must_use]
342 pub const fn contains(self, metric: Metric) -> bool {
343 (self.0 & metric.bit()) != 0
344 }
345
346 /// Returns a new set with `metric` inserted.
347 #[inline]
348 #[must_use]
349 pub const fn with(self, metric: Metric) -> Self {
350 Self(self.0 | metric.bit())
351 }
352
353 /// Returns the union of two sets.
354 #[inline]
355 #[must_use]
356 pub const fn union(self, other: Self) -> Self {
357 Self(self.0 | other.0)
358 }
359
360 /// Insert `metric` (in place).
361 #[inline]
362 pub fn insert(&mut self, metric: Metric) {
363 self.0 |= metric.bit();
364 }
365
366 /// Build a `MetricSet` from a slice, auto-adding the transitive
367 /// dependencies of each selected metric.
368 ///
369 /// This is the workhorse behind
370 /// [`MetricsOptions::with_only`](crate::MetricsOptions::with_only):
371 /// the caller-facing builder enforces the full dependency closure
372 /// so a request for `Mi` alone still computes
373 /// `Loc + Cyclomatic + Halstead`. Exposed `pub` because
374 /// downstream consumers (notably the `bca` Python bindings'
375 /// `parse_metric_names` helper) parse user input into a
376 /// `Vec<Metric>` and need the same closure-resolution semantics
377 /// without re-implementing the worklist.
378 ///
379 /// Implementation note: uses a worklist rather than a single pass
380 /// so a future derived metric whose dependency is itself derived
381 /// still resolves the complete closure. The loop terminates
382 /// because each iteration either inserts a new bit or the
383 /// worklist drains; the bitfield is bounded at `Metric` variant
384 /// count.
385 #[must_use]
386 pub fn from_slice_with_deps(metrics: &[Metric]) -> Self {
387 let mut set = Self::empty();
388 for &m in metrics {
389 set.insert(m);
390 }
391 set.resolved()
392 }
393
394 /// Returns this set closed under [`Metric::dependencies`].
395 ///
396 /// Every selected metric's transitive dependencies are added so a
397 /// set carrying a derived metric (e.g. [`Metric::Mi`]) also carries
398 /// the inputs that metric's finalize step consumes
399 /// ([`Metric::Loc`], [`Metric::Cyclomatic`], [`Metric::Halstead`]).
400 /// Resolving an already-closed set is a no-op, so the operation is
401 /// idempotent: `set.resolved().resolved() == set.resolved()`.
402 ///
403 /// This is the set-in/set-out counterpart of
404 /// [`MetricSet::from_slice_with_deps`] and is what
405 /// [`MetricsOptions::with_metric_set`](crate::MetricsOptions::with_metric_set)
406 /// applies so a caller-supplied set can never select a derived
407 /// metric without its prerequisites (#743).
408 ///
409 /// Implementation note: uses a worklist rather than a single pass
410 /// so a future derived metric whose dependency is itself derived
411 /// still resolves the complete closure. The loop terminates
412 /// because each iteration either inserts a new bit or the worklist
413 /// drains; the bitfield is bounded at `Metric` variant count.
414 #[must_use]
415 pub fn resolved(self) -> Self {
416 let mut set = self;
417 let mut worklist: Vec<Metric> = Metric::ALL
418 .iter()
419 .copied()
420 .filter(|&m| self.contains(m))
421 .collect();
422 while let Some(m) = worklist.pop() {
423 for &dep in m.dependencies() {
424 if !set.contains(dep) {
425 set.insert(dep);
426 worklist.push(dep);
427 }
428 }
429 }
430 set
431 }
432}
433
434impl Default for MetricSet {
435 /// Default = every metric selected, matching the pre-#257
436 /// behaviour of [`MetricsOptions::default`](crate::MetricsOptions::default).
437 #[inline]
438 fn default() -> Self {
439 Self::all()
440 }
441}
442
443#[cfg(test)]
444#[path = "metric_set_tests.rs"]
445mod tests;