big_code_analysis/metric_catalog.rs
1//! Single source of truth for the metric catalog.
2//!
3//! Before this module existed, the same set of offender metric ids was
4//! hand-maintained in three places — [`output::sarif`]'s rule
5//! descriptions, the CLI's threshold extractor table, and a third copy
6//! inside a "does every extractor have a description" test — plus a
7//! fourth, differently-shaped table powering `bca list-metrics`. Those
8//! tables drifted: ten of twenty-one rule-description keys once failed
9//! to match any real offender id and went unnoticed for two model
10//! versions.
11//!
12//! [`METRICS`](crate::metric_catalog::METRICS) is now the canonical
13//! list of offender sub-metric ids (`halstead.volume`, `mi.original`,
14//! …) together with their long-form sentences and
15//! [`Direction`](crate::metric_catalog::Direction).
16//! [`FAMILIES`](crate::metric_catalog::FAMILIES) is the canonical view
17//! that `bca list-metrics` renders. The library's offender formatters
18//! ([`output::sarif`], [`output::code_climate`]) read `METRICS`; the
19//! CLI's threshold engine keys its extractor table off the same ids and
20//! a parity test pins the two id-sets together, so a new metric cannot
21//! ship with a half-updated catalog.
22//!
23//! Each entry also records its [`Scale`](crate::metric_catalog::Scale):
24//! ratio for the counts, interval for the Maintainability Index family,
25//! whose zero is arbitrary and whose values go negative.
26//! [`Threshold`](crate::metric_catalog::Threshold) builds on direction
27//! and scale together. It is a validated limit that answers every value
28//! question a gate asks: whether a value breaches, by how much, how two
29//! breaches rank, where the soft tier sits, and how far a value has
30//! moved from its baseline. Those answers then mean the same thing on
31//! every surface (#1621).
32//!
33//! [`resolve_selector`](crate::metric_catalog::resolve_selector) reads
34//! both tables to turn a *selector* — a name saying what to compute — into
35//! the [`Metric`] family that computes it. `bca metrics --metrics` and the
36//! Python bindings' `metrics=` both resolve through it, so a name copied
37//! from `bca list-metrics` or `bca check --threshold` works in either or
38//! neither (#1614).
39//!
40//! [`output::sarif`]: crate::output
41//! [`output::code_climate`]: crate::output
42
43#![allow(clippy::doc_markdown)]
44
45use std::cmp::Ordering;
46use std::fmt;
47use std::sync::LazyLock;
48
49use crate::Metric;
50use crate::spaces::SpaceKind;
51use crate::suggestion::{closest_names, render_suggestion};
52
53mod threshold;
54pub use threshold::{Change, Checked, LimitError, LimitWarning, Threshold};
55
56/// The space kind a metric's threshold is meaningful on (issue #969).
57///
58/// A threshold gate (`bca check`, the Python `to_sarif` binding) walks
59/// every [`crate::FuncSpace`] — the file-level [`SpaceKind::Unit`] root,
60/// every container (class / impl / ...), and every individual function.
61/// For the subtree-summed accessors a metric's value at any space that
62/// owns children is a *sum across many functions*, so a per-function
63/// limit would fire on every non-trivial file and multi-method `impl`.
64/// Scope records the kind each metric actually measures so the front-ends
65/// gate it there and nowhere else — keeping the CLI gate and the binding
66/// in lockstep, the same way [`Direction`] keeps their breach direction
67/// aligned.
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
69pub enum MetricScope {
70 /// Gate only the whole-file [`SpaceKind::Unit`] root — the `loc.*`
71 /// size family, whose limit is a per-file ceiling.
72 File,
73 /// Gate only individual function spaces ([`SpaceKind::Function`] —
74 /// free functions, methods, closures). The per-function complexity
75 /// metrics (cognitive, cyclomatic, abc, mi.*) and the subtree sums
76 /// that describe one function (halstead.*, nargs, nexits, tokens)
77 /// live here.
78 ///
79 /// Whether "one function" includes its nested closures is per
80 /// metric, not per scope. `halstead.*`, `nexits` and `tokens` read
81 /// subtree sums, because a closure's tokens and exits really are
82 /// part of the enclosing body a reader must follow. `nargs` reads
83 /// the space's own parameters instead (#1196): a closure that opens
84 /// its own space is gated on its own row, and summing its arguments
85 /// into the enclosing signature made the offender's number describe
86 /// something its remediation could not change.
87 Function,
88 /// Gate only container spaces that own methods (class / struct /
89 /// trait / impl / namespace / interface) — the object-oriented size
90 /// metrics `nom`, `wmc`, `npm`, `npa`.
91 Container,
92}
93
94impl MetricScope {
95 /// Whether a threshold with this scope is evaluated against `kind`.
96 ///
97 /// The single source of truth for the kind-filtering both the CLI
98 /// gate and the Python binding apply, so the two cannot drift on
99 /// which space kinds a metric gates.
100 #[must_use]
101 pub fn admits(self, kind: SpaceKind) -> bool {
102 match self {
103 Self::File => matches!(kind, SpaceKind::Unit),
104 Self::Function => matches!(kind, SpaceKind::Function),
105 Self::Container => matches!(
106 kind,
107 SpaceKind::Class
108 | SpaceKind::Struct
109 | SpaceKind::Trait
110 | SpaceKind::Impl
111 | SpaceKind::Namespace
112 | SpaceKind::Interface
113 ),
114 }
115 }
116}
117
118/// Which direction of a metric's value is unhealthy.
119///
120/// Most metrics grow worse as they grow larger; the Maintainability
121/// Index family is the inverse — a *lower* value is worse. [`Threshold`]
122/// reads it for every breach, severity and soft-tier question, and the
123/// rule sentences use it to pick "exceeds" vs "falls below" phrasing.
124#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
125pub enum Direction {
126 /// A higher value is worse (cyclomatic, halstead.*, loc.*, …).
127 HigherIsWorse,
128 /// A lower value is worse (the `mi.*` Maintainability Index family).
129 LowerIsWorse,
130}
131
132/// The measurement scale of a metric's value: which arithmetic on it
133/// means anything (issue #1621).
134///
135/// Stevens' classification of measurement scales (*On the Theory of
136/// Scales of Measurement*, Science 103, 1946) is the reason this is a
137/// separate axis from [`Direction`]. On a **ratio** scale zero means
138/// "none of it", so `value / limit` and "N percent worse" are
139/// meaningful: 30 branches is twice 15. On an **interval** scale the
140/// zero is arbitrary, so only *differences* are meaningful: a
141/// Maintainability Index of -10 is not "twice as bad" as -5, and a drop
142/// from 0.001 to -0.0097 is a hundredth of a point, not a 1069 % fall.
143/// Every surface that ranks, scales or reports a change reads this
144/// instead of assuming ratio arithmetic; [`Threshold`] is where that
145/// arithmetic lives.
146///
147/// `#[non_exhaustive]`, as is the `Interval` variant, so a scale or a
148/// field can be added in a minor release. Match with a wildcard arm and
149/// `Interval { unit, .. }`.
150#[derive(Debug, Clone, Copy, PartialEq)]
151#[non_exhaustive]
152pub enum Scale {
153 /// True zero, values `>= 0`: every count and count-derived metric
154 /// (`cyclomatic`, `halstead.*`, `loc.*`, …). Severity is the plain
155 /// `value / limit` ratio and a change is a percentage.
156 Ratio,
157 /// Arbitrary zero: the Maintainability Index family. Only
158 /// differences are meaningful, so severity and change are measured
159 /// in multiples of `unit`.
160 #[non_exhaustive]
161 Interval {
162 /// Smallest value the metric can take (may be `-INFINITY`).
163 min: f64,
164 /// Largest value the metric can take (may be `INFINITY`).
165 max: f64,
166 /// One step of badness, in the metric's own points: the
167 /// distance that plays the role a `Ratio` metric's limit plays
168 /// in severity, soft-tier scaling and drift. Always finite and
169 /// positive.
170 unit: f64,
171 },
172}
173
174/// One Visual Studio maintainability band, in Visual Studio points.
175///
176/// Visual Studio rates a method green at 20–100, yellow at 10–19 and
177/// red at 0–9
178/// (<https://learn.microsoft.com/visualstudio/code-quality/code-metrics-maintainability-index-range-and-meaning>);
179/// radon's A/B/C ranks use the same 10/20 edges
180/// (<https://radon.readthedocs.io/en/latest/intro.html>). One band is
181/// therefore 10 points.
182const MI_VISUAL_STUDIO_UNIT: f64 = 10.0;
183
184/// One Visual Studio band expressed on the original 171-point scale.
185///
186/// `mi.visual_studio` is `mi.original · 100 / 171` (clamped at 0), so a
187/// 10-point Visual Studio band is `10 · 171 / 100 = 17.1` original
188/// points. `mi.sei` keeps the original formula's 171-point scale and
189/// adds a comment term; it takes the same unit by convention rather
190/// than by derivation.
191const MI_ORIGINAL_UNIT: f64 = MI_VISUAL_STUDIO_UNIT * 171.0 / 100.0;
192
193/// `mi.original` / `mi.sei`: unbounded and routinely negative.
194const MI_UNBOUNDED: Scale = Scale::Interval {
195 min: f64::NEG_INFINITY,
196 max: f64::INFINITY,
197 unit: MI_ORIGINAL_UNIT,
198};
199
200/// `mi.visual_studio`: the original formula rescaled to and clamped
201/// into `[0, 100]`.
202const MI_PERCENT: Scale = Scale::Interval {
203 min: 0.0,
204 max: 100.0,
205 unit: MI_VISUAL_STUDIO_UNIT,
206};
207
208/// Catalog entry for one offender-emitting sub-metric id.
209///
210/// The `id` is the dotted key the threshold engine emits for an
211/// offender (`halstead.volume`); `family` groups ids under a top-level
212/// metric (`halstead`) and must match a [`MetricFamily::name`].
213///
214/// `#[non_exhaustive]`: these are read-only records the library
215/// constructs (downstream consumers read fields, never build them), so
216/// a new field can be added in a future minor without a SemVer break.
217#[derive(Debug, Clone, Copy)]
218#[non_exhaustive]
219pub struct MetricInfo {
220 /// Dotted offender id, e.g. `"halstead.volume"` or `"cognitive"`.
221 pub id: &'static str,
222 /// Top-level family this id belongs to, e.g. `"halstead"`.
223 pub family: &'static str,
224 /// Long-form sentence for SARIF `rule.shortDescription.text` and
225 /// the Code Climate `description` prefix.
226 pub long_description: &'static str,
227 /// Whether a higher or lower value is the unhealthy direction.
228 pub direction: Direction,
229 /// Whether the metric's JSON headline is an aggregate across
230 /// descendant spaces (a `sum`/`*_sum` field) that does **not** match
231 /// the CLI threshold accessor's per-space scalar at any interior
232 /// space.
233 ///
234 /// `true` for the five metrics whose serialized headline diverges
235 /// from the per-space accessor — `cognitive`, `cyclomatic`,
236 /// `cyclomatic.modified`, `abc` (#441) and, since #1196 moved its
237 /// gate onto the callable's own parameter list, `nargs`. The
238 /// aggregate is at least as large everywhere and equal only where
239 /// the descendants contribute nothing — always at a leaf space (no
240 /// descendant function/closure spaces), and incidentally at an
241 /// interior one whose descendants all score zero.
242 ///
243 /// This flag describes the `sum`/`*_sum`/`total` *aggregate* field,
244 /// which still diverges. As of #958 (and #1236 for `nargs`) the wire
245 /// shape **also** serializes each of these metrics' per-space own
246 /// value (`cyclomatic.value`, `cyclomatic.modified.value`,
247 /// `cognitive.value`, `abc.value`, `nargs.value`), so a JSON-walking
248 /// front-end no longer needs this flag to stay correct: it reads the
249 /// own value directly and emits at every space exactly like the CLI.
250 /// The Python `to_sarif` binding was switched to that path in #958;
251 /// before it, the binding emitted these only at leaf spaces to avoid
252 /// subtree-wide values masquerading as per-space findings the CLI
253 /// never produces (#855). The flag name retains its original
254 /// unit-only framing.
255 ///
256 /// The flag is **not** derivable from the JSON path string: `nexits`
257 /// also serialises a `sum` field, but its CLI accessor (`nexits_sum()`)
258 /// reads that same aggregate, so it does not diverge and is `false`.
259 /// The divergence is between the JSON field and the CLI accessor,
260 /// which only this registry now records once for both front-ends to
261 /// share (#442).
262 pub skip_at_unit: bool,
263 /// The space kind this metric's threshold gates (issue #969). Both
264 /// the CLI threshold engine and the Python `to_sarif` binding read
265 /// this to skip spaces a metric does not measure, so a metric's
266 /// file-wide or `impl`-wide aggregate never fires as a per-function
267 /// limit. See [`MetricScope`].
268 pub scope: MetricScope,
269 /// The measurement scale of the metric's value (issue #1621):
270 /// [`Scale::Ratio`] for every count, [`Scale::Interval`] for the
271 /// `mi.*` family. See [`Scale`].
272 pub scale: Scale,
273}
274
275impl MetricInfo {
276 /// Order two values of this metric by badness: [`Ordering::Greater`]
277 /// means `a` is worse than `b`.
278 ///
279 /// The comparison is oriented by [`direction`](Self::direction) and
280 /// is a total order, so it is safe to hand to a sort. A NaN sorts as
281 /// the **worst** value in either direction (and two NaNs compare
282 /// equal), matching how a NaN measurement is treated as a regression
283 /// rather than silently passing. `-0.0` and `0.0` compare equal, as
284 /// they do under [`Threshold::breaches`].
285 #[must_use]
286 pub fn cmp_worse(&self, a: f64, b: f64) -> Ordering {
287 // Neither operand is NaN in the last arm, so `partial_cmp` is
288 // total there; `total_cmp` would rank `-0.0` below `0.0` and read
289 // an MI of `-0.0` against a record of `0.0` as a regression.
290 let numeric = |x: f64, y: f64| x.partial_cmp(&y).unwrap_or(Ordering::Equal);
291 match (a.is_nan(), b.is_nan()) {
292 (true, true) => Ordering::Equal,
293 (true, false) => Ordering::Greater,
294 (false, true) => Ordering::Less,
295 (false, false) => match self.direction {
296 Direction::HigherIsWorse => numeric(a, b),
297 Direction::LowerIsWorse => numeric(b, a),
298 },
299 }
300 }
301}
302
303/// A `bca list-metrics` row: the bare name printed in `names` mode and
304/// the one-line summary printed in `descriptions` mode.
305///
306/// `#[non_exhaustive]` for the same forward-compat reason as
307/// [`MetricInfo`].
308#[derive(Debug, Clone, Copy)]
309#[non_exhaustive]
310pub struct MetricRow {
311 /// Bare name printed one-per-line by `list-metrics`, e.g.
312 /// `"halstead"` or `"sloc"`. Downstream tooling (`bca diff`, which
313 /// buckets per-file metric deltas by these names) relies on them, so
314 /// they are an external contract.
315 pub name: &'static str,
316 /// One-line description printed in `list-metrics descriptions` mode.
317 pub summary: &'static str,
318}
319
320/// A top-level metric family as surfaced by `bca list-metrics`.
321///
322/// Most families render as a single [`MetricRow`] whose name equals
323/// [`name`](Self::name). `loc` is the exception: it renders one row per
324/// sub-measurement (`sloc`, `ploc`, …) because those bare names are an
325/// external grep contract.
326///
327/// `#[non_exhaustive]` for the same forward-compat reason as
328/// [`MetricInfo`].
329#[derive(Debug, Clone, Copy)]
330#[non_exhaustive]
331pub struct MetricFamily {
332 /// Family key, e.g. `"halstead"`, `"loc"`. Matches
333 /// [`MetricInfo::family`].
334 pub name: &'static str,
335 /// `list-metrics` rows for this family, in display order.
336 pub rows: &'static [MetricRow],
337}
338
339/// Canonical offender sub-metric catalog. Long-form sentences and the
340/// `mi.*` lower-is-worse direction moved here verbatim from the former
341/// `output::rule_descriptions` table.
342///
343/// `#[rustfmt::skip]`: the one-row-per-entry layout keeps the table
344/// scannable; rustfmt would otherwise wrap each struct over many lines.
345#[rustfmt::skip]
346pub const METRICS: &[MetricInfo] = &[
347 MetricInfo { id: "cognitive", family: "cognitive", long_description: "Cognitive Complexity exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: true, scope: MetricScope::Function, scale: Scale::Ratio },
348 MetricInfo { id: "cyclomatic", family: "cyclomatic", long_description: "Cyclomatic Complexity exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: true, scope: MetricScope::Function, scale: Scale::Ratio },
349 MetricInfo { id: "cyclomatic.modified", family: "cyclomatic", long_description: "Modified Cyclomatic Complexity exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: true, scope: MetricScope::Function, scale: Scale::Ratio },
350 MetricInfo { id: "halstead.volume", family: "halstead", long_description: "Halstead volume exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: Scale::Ratio },
351 MetricInfo { id: "halstead.difficulty", family: "halstead", long_description: "Halstead difficulty exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: Scale::Ratio },
352 MetricInfo { id: "halstead.effort", family: "halstead", long_description: "Halstead effort exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: Scale::Ratio },
353 MetricInfo { id: "halstead.time", family: "halstead", long_description: "Halstead time-to-program exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: Scale::Ratio },
354 MetricInfo { id: "halstead.bugs", family: "halstead", long_description: "Estimated Halstead bugs exceed the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: Scale::Ratio },
355 MetricInfo { id: "loc.sloc", family: "loc", long_description: "Source lines of code exceed the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File, scale: Scale::Ratio },
356 MetricInfo { id: "loc.ploc", family: "loc", long_description: "Physical lines of code exceed the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File, scale: Scale::Ratio },
357 MetricInfo { id: "loc.lloc", family: "loc", long_description: "Logical lines of code exceed the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File, scale: Scale::Ratio },
358 MetricInfo { id: "loc.cloc", family: "loc", long_description: "Comment lines of code exceed the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File, scale: Scale::Ratio },
359 MetricInfo { id: "loc.blank", family: "loc", long_description: "Blank lines of code exceed the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File, scale: Scale::Ratio },
360 MetricInfo { id: "loc.dloc", family: "loc", long_description: "Documentation lines of code exceed the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::File, scale: Scale::Ratio },
361 MetricInfo { id: "nom", family: "nom", long_description: "Number of methods/functions exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Container, scale: Scale::Ratio },
362 MetricInfo { id: "tokens", family: "tokens", long_description: "Number of tokens exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: Scale::Ratio },
363 MetricInfo { id: "nexits", family: "nexits", long_description: "Number of exit points exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: Scale::Ratio },
364 MetricInfo { id: "nargs", family: "nargs", long_description: "Number of function arguments exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: true, scope: MetricScope::Function, scale: Scale::Ratio },
365 MetricInfo { id: "mi.original", family: "mi", long_description: "Maintainability Index falls below the configured threshold.", direction: Direction::LowerIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: MI_UNBOUNDED },
366 MetricInfo { id: "mi.sei", family: "mi", long_description: "Maintainability Index (SEI) falls below the configured threshold.", direction: Direction::LowerIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: MI_UNBOUNDED },
367 MetricInfo { id: "mi.visual_studio", family: "mi", long_description: "Maintainability Index (Visual Studio) falls below the configured threshold.", direction: Direction::LowerIsWorse, skip_at_unit: false, scope: MetricScope::Function, scale: MI_PERCENT },
368 MetricInfo { id: "abc", family: "abc", long_description: "ABC magnitude exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: true, scope: MetricScope::Function, scale: Scale::Ratio },
369 MetricInfo { id: "wmc", family: "wmc", long_description: "Weighted Methods per Class exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Container, scale: Scale::Ratio },
370 MetricInfo { id: "npm", family: "npm", long_description: "Number of public methods exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Container, scale: Scale::Ratio },
371 MetricInfo { id: "npa", family: "npa", long_description: "Number of public attributes exceeds the configured threshold.", direction: Direction::HigherIsWorse, skip_at_unit: false, scope: MetricScope::Container, scale: Scale::Ratio },
372];
373
374/// Canonical `bca list-metrics` view. Family summaries moved here
375/// verbatim from the CLI's former hand-maintained catalog. Declaration
376/// order is the `list-metrics` print order.
377///
378/// Only `loc` expands to multiple rows; every other family is a single
379/// row whose name equals the family name.
380pub const FAMILIES: &[MetricFamily] = &[
381 MetricFamily {
382 name: "cognitive",
383 rows: &[MetricRow {
384 name: "cognitive",
385 summary: "Cognitive Complexity: how difficult code is to understand.",
386 }],
387 },
388 MetricFamily {
389 name: "cyclomatic",
390 rows: &[MetricRow {
391 name: "cyclomatic",
392 summary: "Cyclomatic Complexity: linearly independent paths through the code; the modified variant collapses switch/match/when arms in a single switch statement into one decision point.",
393 }],
394 },
395 MetricFamily {
396 name: "halstead",
397 rows: &[MetricRow {
398 name: "halstead",
399 summary: "Halstead suite: vocabulary, length, volume, difficulty, effort, time, bugs.",
400 }],
401 },
402 MetricFamily {
403 name: "loc",
404 rows: &[
405 MetricRow {
406 name: "sloc",
407 summary: "Source lines of code: total lines in a source file.",
408 },
409 MetricRow {
410 name: "ploc",
411 summary: "Physical lines of code: instruction lines.",
412 },
413 MetricRow {
414 name: "lloc",
415 summary: "Logical lines of code: statement count.",
416 },
417 MetricRow {
418 name: "cloc",
419 summary: "Comment lines of code.",
420 },
421 MetricRow {
422 name: "blank",
423 summary: "Blank lines.",
424 },
425 MetricRow {
426 name: "dloc",
427 summary: "Documentation lines of code: doc comments, docstrings and POD, a subset of cloc.",
428 },
429 ],
430 },
431 MetricFamily {
432 name: "nom",
433 rows: &[MetricRow {
434 name: "nom",
435 summary: "Number of methods and closures.",
436 }],
437 },
438 MetricFamily {
439 name: "tokens",
440 rows: &[MetricRow {
441 name: "tokens",
442 summary: "Per-function token count: AST leaves excluding comments.",
443 }],
444 },
445 MetricFamily {
446 name: "nexits",
447 rows: &[MetricRow {
448 name: "nexits",
449 summary: "Number of exit points from a function or method.",
450 }],
451 },
452 MetricFamily {
453 name: "nargs",
454 rows: &[MetricRow {
455 name: "nargs",
456 summary: "Number of arguments to a function or method.",
457 }],
458 },
459 MetricFamily {
460 name: "mi",
461 rows: &[MetricRow {
462 name: "mi",
463 summary: "Maintainability Index suite.",
464 }],
465 },
466 MetricFamily {
467 name: "abc",
468 rows: &[MetricRow {
469 name: "abc",
470 summary: "ABC: assignments, branches, and conditions.",
471 }],
472 },
473 MetricFamily {
474 name: "wmc",
475 rows: &[MetricRow {
476 name: "wmc",
477 summary: "Weighted Methods per Class.",
478 }],
479 },
480 MetricFamily {
481 name: "npm",
482 rows: &[MetricRow {
483 name: "npm",
484 summary: "Number of public methods of a class.",
485 }],
486 },
487 MetricFamily {
488 name: "npa",
489 rows: &[MetricRow {
490 name: "npa",
491 summary: "Number of public attributes of a class.",
492 }],
493 },
494];
495
496/// Catalog entry for a known offender id, or `None`. Callers pick their
497/// own fallback for unknown ids (SARIF emits the raw id; Code Climate
498/// falls through to its default message).
499///
500/// Public so out-of-crate consumers (the CLI threshold engine) can read
501/// a metric's [`Direction`] — the `mi.*` family is lower-is-worse, so
502/// the gate and the offender wording must consult it rather than
503/// assuming a higher value is always the violation (#698).
504#[must_use]
505pub fn lookup(id: &str) -> Option<&'static MetricInfo> {
506 METRICS.iter().find(|m| m.id == id)
507}
508
509/// Whether a lower value of the metric `id` is the unhealthy direction
510/// (the `mi.*` Maintainability Index family). An id the catalog does not
511/// know defaults to higher-is-worse (#698).
512///
513/// Deprecated since 2.4.0 (#1621): a bare direction bool cannot say how
514/// far past a limit a value is, and every consumer that took one
515/// re-derived that arithmetic itself. No caller in this workspace uses
516/// it any more, and the root `clippy.toml` keeps it that way. Read
517/// [`MetricInfo::direction`] via [`lookup`], or build a [`Threshold`]
518/// and ask it. Removal is reserved for 3.0.
519#[deprecated(
520 since = "2.4.0",
521 note = "read `MetricInfo::direction` via `lookup`, or build a `Threshold`"
522)]
523#[must_use]
524pub fn lower_is_worse(id: &str) -> bool {
525 lookup(id).is_some_and(|m| matches!(m.direction, Direction::LowerIsWorse))
526}
527
528/// The [`MetricScope`] of metric `id` — the space kind its threshold
529/// gates (issue #969). `None` for an id the catalog does not know; both
530/// front-ends treat an unknown id as a usage error before reaching here,
531/// so the `None` arm is only a defensive fallback.
532#[must_use]
533pub fn scope(id: &str) -> Option<MetricScope> {
534 lookup(id).map(|m| m.scope)
535}
536
537/// The [`Scale`] of metric `id` (issue #1621), or `None` for an id the
538/// catalog does not know. The free-function twin of [`scope`].
539#[must_use]
540pub fn scale(id: &str) -> Option<Scale> {
541 lookup(id).map(|m| m.scale)
542}
543
544/// The names [`resolve_selector`] advertises: every family name (`loc`,
545/// `halstead`, …) and every `bca list-metrics` row name (`sloc`, `dloc`,
546/// …), sorted and deduplicated.
547///
548/// The dotted [`METRICS`] ids (`cyclomatic.modified`, `mi.original`)
549/// resolve too but are not listed: each names a field inside a family
550/// these names already cover, and listing all of them would bury the
551/// families in an error message.
552#[must_use]
553pub fn selector_names() -> &'static [&'static str] {
554 // Built once: `UnknownSelectorError`'s `Display` and its suggestion
555 // search both read the list, and it is fixed at compile time.
556 static NAMES: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
557 let mut names: Vec<&'static str> = FAMILIES
558 .iter()
559 .flat_map(|family| {
560 std::iter::once(family.name).chain(family.rows.iter().map(|row| row.name))
561 })
562 .collect();
563 names.sort_unstable();
564 names.dedup();
565 names
566 });
567 &NAMES
568}
569
570/// Resolve a metric selector to the [`Metric`] family that computes it.
571///
572/// A selector is any name `bca metrics --metrics` accepts:
573///
574/// - a family name (`loc`, `cyclomatic`), the [`Metric`] `Display`
575/// spelling;
576/// - a `bca list-metrics` row name (`sloc`, `dloc`, …), which selects
577/// the family that owns the row;
578/// - a dotted [`METRICS`] id (`cyclomatic.modified`, `loc.sloc`,
579/// `mi.original`), which selects its family.
580///
581/// Selection is family-granular, as the walk computes whole families:
582/// `"dloc"` and `"loc"` both select [`Metric::Loc`], and nothing narrows
583/// the output to the named field. Matching is strict lowercase, like
584/// [`Metric`]'s `FromStr`.
585///
586/// # Errors
587///
588/// [`UnknownSelectorError`] when `name` is none of the above. It carries
589/// the closest [`selector_names`] as a did-you-mean hint.
590pub fn resolve_selector(name: &str) -> Result<Metric, UnknownSelectorError> {
591 FAMILIES
592 .iter()
593 .find(|family| family.name == name || family.rows.iter().any(|row| row.name == name))
594 .map(|family| family.name)
595 .or_else(|| lookup(name).map(|info| info.family))
596 // Every family name is a `Metric` spelling
597 // (`every_selector_name_resolves` pins it), so this drops nothing.
598 .and_then(|family| family.parse::<Metric>().ok())
599 .ok_or_else(|| UnknownSelectorError::new(name))
600}
601
602/// Error returned by [`resolve_selector`] for a name that is no metric
603/// selector.
604///
605/// Carries the rejected input and the did-you-mean candidates, drawn
606/// from [`selector_names`]. `Display` renders
607/// `unknown metric: <input>[; did you mean …]; known metrics: <names>`,
608/// the message the Python bindings raise; its exact wording is not
609/// SemVer-protected. A front end that wants its own wording composes it
610/// from [`input`](Self::input), [`suggestions`](Self::suggestions) and
611/// [`selector_names`].
612#[derive(Debug, Clone, PartialEq, Eq)]
613pub struct UnknownSelectorError {
614 input: String,
615 suggestions: Vec<&'static str>,
616}
617
618impl UnknownSelectorError {
619 fn new(input: &str) -> Self {
620 Self {
621 input: input.to_owned(),
622 suggestions: closest_names(input, selector_names()),
623 }
624 }
625
626 /// The rejected input, verbatim.
627 #[must_use]
628 pub fn input(&self) -> &str {
629 &self.input
630 }
631
632 /// The [`selector_names`] closest to the input, best first; empty
633 /// when nothing is close. At most three, all tied on closeness.
634 #[must_use]
635 pub fn suggestions(&self) -> &[&'static str] {
636 &self.suggestions
637 }
638}
639
640impl fmt::Display for UnknownSelectorError {
641 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
642 write!(
643 f,
644 "unknown metric: {}{}; known metrics: {}",
645 self.input,
646 render_suggestion(&self.suggestions),
647 selector_names().join(", ")
648 )
649 }
650}
651
652impl std::error::Error for UnknownSelectorError {}
653
654#[cfg(test)]
655mod tests {
656 use super::*;
657 use std::collections::HashSet;
658
659 #[test]
660 fn metric_ids_are_unique() {
661 let mut seen = HashSet::new();
662 for m in METRICS {
663 assert!(seen.insert(m.id), "duplicate metric id {:?}", m.id);
664 }
665 }
666
667 #[test]
668 fn family_names_are_unique() {
669 let mut seen = HashSet::new();
670 for f in FAMILIES {
671 assert!(seen.insert(f.name), "duplicate family name {:?}", f.name);
672 }
673 }
674
675 #[test]
676 fn every_metric_family_is_declared() {
677 let families: HashSet<&str> = FAMILIES.iter().map(|f| f.name).collect();
678 for m in METRICS {
679 assert!(
680 families.contains(m.family),
681 "metric {:?} references undeclared family {:?}",
682 m.id,
683 m.family,
684 );
685 }
686 }
687
688 #[test]
689 fn every_family_has_a_metric() {
690 let metric_families: HashSet<&str> = METRICS.iter().map(|m| m.family).collect();
691 for f in FAMILIES {
692 assert!(
693 metric_families.contains(f.name),
694 "family {:?} has no METRICS entry",
695 f.name,
696 );
697 }
698 }
699
700 #[test]
701 fn lookup_round_trips_and_rejects_unknown() {
702 for m in METRICS {
703 assert_eq!(lookup(m.id).map(|i| i.id), Some(m.id));
704 }
705 assert!(lookup("not.a.metric").is_none());
706 }
707
708 /// `mi.*` is the only lower-is-worse family. This pins the data that
709 /// replaced the former `is_lower_is_worse` prefix predicate; if the
710 /// `Direction` of an `mi.*` row is flipped (or a non-`mi` row is
711 /// marked `LowerIsWorse`), Code Climate's breach-ratio inversion
712 /// silently flips with it.
713 #[test]
714 fn lower_is_worse_iff_mi_family() {
715 for m in METRICS {
716 let expect_lower = m.family == "mi";
717 assert_eq!(
718 matches!(m.direction, Direction::LowerIsWorse),
719 expect_lower,
720 "metric {:?} has the wrong direction",
721 m.id,
722 );
723 }
724 }
725
726 // Pins the deprecated helper's contract until 3.0 removes it.
727 #[allow(deprecated, clippy::disallowed_methods)]
728 #[test]
729 fn lower_is_worse_helper_matches_catalog_and_defaults_false() {
730 assert!(lower_is_worse("mi.original"), "mi.* is lower-is-worse");
731 assert!(
732 !lower_is_worse("cyclomatic"),
733 "cyclomatic is higher-is-worse"
734 );
735 // An id the catalog does not know defaults to higher-is-worse, so
736 // the shared gate never flags an unknown metric on the wrong side.
737 assert!(!lower_is_worse("not_a_metric"));
738 }
739
740 /// `mi.*` sentences phrase the breach as "falls below"; every other
741 /// metric phrases it as "exceeds"/"exceed". This pins the wording to
742 /// the direction so a copy-paste sentence with the wrong verb is
743 /// caught.
744 #[test]
745 fn sentence_phrasing_matches_direction() {
746 for m in METRICS {
747 match m.direction {
748 Direction::LowerIsWorse => assert!(
749 m.long_description.contains("falls below"),
750 "{:?} should use `falls below`: {:?}",
751 m.id,
752 m.long_description,
753 ),
754 Direction::HigherIsWorse => assert!(
755 m.long_description.contains("exceed"),
756 "{:?} should use `exceed(s)`: {:?}",
757 m.id,
758 m.long_description,
759 ),
760 }
761 }
762 }
763
764 /// `skip_at_unit` is `true` for exactly the metrics whose serialized
765 /// JSON headline at the file-level `unit` space is an aggregate over
766 /// descendant spaces that does not match the CLI threshold
767 /// accessor's per-space scalar (#441).
768 ///
769 /// Since #958 the flag has no consumer: both front-ends read a
770 /// per-space `value` field instead, and
771 /// `big-code-analysis-py/src/sarif.rs` pins its own JSON paths
772 /// (`metric_field_paths_are_pinned`) rather than deriving them from
773 /// this flag. So what this enumeration buys is a review tripwire,
774 /// not a derived contract — which is exactly what it failed to be
775 /// between #1196 and #1236: #1196 moved the `nargs` gate from
776 /// `total()` (a subtree sum) to the callable's own parameter list
777 /// while `nargs.total` kept its meaning, and the row here stayed
778 /// `false`, leaving the registry asserting a parity that no longer
779 /// held. Editing the set means re-deriving it against the CLI
780 /// `EXTRACTORS` accessors, one row at a time.
781 ///
782 /// The property is deliberately enumerated rather than derived from
783 /// the id string: `nexits` also serialises a `sum` field but reads
784 /// that same aggregate via its CLI accessor, so it does not diverge.
785 #[test]
786 fn skip_at_unit_is_the_sum_vs_per_space_divergence_set() {
787 let mut skip: Vec<&str> = METRICS
788 .iter()
789 .filter(|m| m.skip_at_unit)
790 .map(|m| m.id)
791 .collect();
792 skip.sort_unstable();
793 assert_eq!(
794 skip,
795 [
796 "abc",
797 "cognitive",
798 "cyclomatic",
799 "cyclomatic.modified",
800 "nargs"
801 ],
802 "skip_at_unit set drifted from the JSON-aggregate-vs-CLI-accessor \
803 property; review against the CLI EXTRACTORS accessors before editing",
804 );
805 }
806
807 /// The per-metric [`MetricScope`] partition (#969): `loc.*` gates the
808 /// file root, the OO size metrics gate containers, everything else
809 /// gates leaf functions. Enumerated so a new metric must be placed
810 /// deliberately rather than defaulting silently — both the CLI gate
811 /// and the Python binding derive their kind-filtering from this.
812 #[test]
813 fn scope_partitions_metrics_by_measured_kind() {
814 let by_scope = |want: MetricScope| {
815 let mut ids: Vec<&str> = METRICS
816 .iter()
817 .filter(|m| m.scope == want)
818 .map(|m| m.id)
819 .collect();
820 ids.sort_unstable();
821 ids
822 };
823 assert_eq!(
824 by_scope(MetricScope::File),
825 [
826 "loc.blank",
827 "loc.cloc",
828 "loc.dloc",
829 "loc.lloc",
830 "loc.ploc",
831 "loc.sloc"
832 ],
833 "only the loc.* size family is File-scoped",
834 );
835 assert_eq!(
836 by_scope(MetricScope::Container),
837 ["nom", "npa", "npm", "wmc"],
838 "only the OO size metrics are Container-scoped",
839 );
840 // Everything else is per-function; spot-check the representatives
841 // and confirm the partition is total (no metric left unscoped).
842 for id in [
843 "cognitive",
844 "cyclomatic",
845 "halstead.effort",
846 "nargs",
847 "nexits",
848 "abc",
849 "mi.original",
850 ] {
851 assert_eq!(
852 scope(id),
853 Some(MetricScope::Function),
854 "{id} should be Function-scoped"
855 );
856 }
857 let counted = by_scope(MetricScope::File).len()
858 + by_scope(MetricScope::Function).len()
859 + by_scope(MetricScope::Container).len();
860 assert_eq!(
861 counted,
862 METRICS.len(),
863 "every metric must have exactly one scope"
864 );
865 }
866
867 /// Exactly the three `mi.*` rows are interval-scale (#1621), each
868 /// with a finite positive unit and an ordered domain; every count is
869 /// ratio-scale.
870 #[test]
871 #[allow(clippy::float_cmp)] // The derived units are exact constants.
872 fn scale_is_interval_for_exactly_the_mi_family() {
873 let mut interval: Vec<&str> = Vec::new();
874 for m in METRICS {
875 match m.scale {
876 Scale::Interval { min, max, unit } => {
877 assert_eq!(m.family, "mi", "{} is interval-scale", m.id);
878 assert!(unit.is_finite() && unit > 0.0, "{} unit {unit}", m.id);
879 assert!(min <= max, "{} domain [{min}, {max}]", m.id);
880 interval.push(m.id);
881 }
882 Scale::Ratio => assert_ne!(m.family, "mi", "{} is ratio-scale", m.id),
883 }
884 }
885 interval.sort_unstable();
886 assert_eq!(interval, ["mi.original", "mi.sei", "mi.visual_studio"]);
887
888 // One Visual Studio band (10 points) on the 171-point scale.
889 assert_eq!(MI_ORIGINAL_UNIT, 17.1);
890 for id in ["mi.original", "mi.sei"] {
891 assert_eq!(
892 scale(id),
893 Some(Scale::Interval {
894 min: f64::NEG_INFINITY,
895 max: f64::INFINITY,
896 unit: 17.1
897 }),
898 "{id}"
899 );
900 }
901 assert_eq!(
902 scale("mi.visual_studio"),
903 Some(Scale::Interval {
904 min: 0.0,
905 max: 100.0,
906 unit: 10.0
907 })
908 );
909 }
910
911 #[test]
912 fn cmp_worse_orients_by_direction() {
913 let cyc = lookup("cyclomatic").expect("known");
914 assert_eq!(cyc.cmp_worse(20.0, 10.0), Ordering::Greater);
915 assert_eq!(cyc.cmp_worse(10.0, 20.0), Ordering::Less);
916 assert_eq!(cyc.cmp_worse(3.0, 3.0), Ordering::Equal);
917
918 let mi = lookup("mi.original").expect("known");
919 assert_eq!(mi.cmp_worse(-20.0, 10.0), Ordering::Greater);
920 assert_eq!(mi.cmp_worse(10.0, -20.0), Ordering::Less);
921 // Signed zeros are one value, as they are to `breaches`: an MI of
922 // `-0.0` against a record of `0.0` is not a regression.
923 for info in [cyc, mi] {
924 assert_eq!(info.cmp_worse(-0.0, 0.0), Ordering::Equal, "{}", info.id);
925 assert_eq!(info.cmp_worse(0.0, -0.0), Ordering::Equal, "{}", info.id);
926 }
927 }
928
929 /// NaN sorts as the worst value in either direction, so a sort by
930 /// `cmp_worse` puts a NaN measurement with the regressions.
931 #[test]
932 fn cmp_worse_ranks_nan_worst_in_both_directions() {
933 for id in ["cyclomatic", "mi.original"] {
934 let info = lookup(id).expect("known");
935 for finite in [f64::NEG_INFINITY, -5.0, 0.0, 1e9, f64::INFINITY] {
936 assert_eq!(info.cmp_worse(f64::NAN, finite), Ordering::Greater, "{id}");
937 assert_eq!(info.cmp_worse(finite, f64::NAN), Ordering::Less, "{id}");
938 }
939 assert_eq!(info.cmp_worse(f64::NAN, -f64::NAN), Ordering::Equal, "{id}");
940 let mut values = [3.0, f64::NAN, -2.0, 7.0];
941 values.sort_by(|a, b| info.cmp_worse(*a, *b));
942 assert!(values[3].is_nan(), "{id}: {values:?}");
943 }
944 }
945
946 /// [`MetricScope::admits`] gates exactly the intended kinds: File only
947 /// the `Unit` root, Function only `Function`, Container the
948 /// method-owning kinds — and nothing admits `Unknown`.
949 #[test]
950 fn scope_admits_only_its_kinds() {
951 assert!(MetricScope::File.admits(SpaceKind::Unit));
952 assert!(!MetricScope::File.admits(SpaceKind::Function));
953 assert!(!MetricScope::File.admits(SpaceKind::Class));
954
955 assert!(MetricScope::Function.admits(SpaceKind::Function));
956 assert!(!MetricScope::Function.admits(SpaceKind::Unit));
957 assert!(!MetricScope::Function.admits(SpaceKind::Impl));
958
959 for kind in [
960 SpaceKind::Class,
961 SpaceKind::Struct,
962 SpaceKind::Trait,
963 SpaceKind::Impl,
964 SpaceKind::Namespace,
965 SpaceKind::Interface,
966 ] {
967 assert!(
968 MetricScope::Container.admits(kind),
969 "{kind:?} is a container"
970 );
971 }
972 assert!(!MetricScope::Container.admits(SpaceKind::Unit));
973 assert!(!MetricScope::Container.admits(SpaceKind::Function));
974
975 for scope in [
976 MetricScope::File,
977 MetricScope::Function,
978 MetricScope::Container,
979 ] {
980 assert!(
981 !scope.admits(SpaceKind::Unknown),
982 "{scope:?} must not admit Unknown"
983 );
984 }
985 }
986
987 /// The advertised vocabulary, pinned whole: the thirteen family names
988 /// plus the six `loc` row names, which is also the list the CLI's
989 /// `--metrics` error has printed since #1597 added `dloc`.
990 #[test]
991 fn selector_names_are_families_plus_rows() {
992 assert_eq!(
993 selector_names(),
994 [
995 "abc",
996 "blank",
997 "cloc",
998 "cognitive",
999 "cyclomatic",
1000 "dloc",
1001 "halstead",
1002 "lloc",
1003 "loc",
1004 "mi",
1005 "nargs",
1006 "nexits",
1007 "nom",
1008 "npa",
1009 "npm",
1010 "ploc",
1011 "sloc",
1012 "tokens",
1013 "wmc",
1014 ]
1015 );
1016 }
1017
1018 /// No advertised name is rejected: the resolver's `parse` step would
1019 /// drop a catalog family with no `Metric` variant, and this is what
1020 /// says so.
1021 #[test]
1022 fn every_selector_name_resolves() {
1023 for name in selector_names() {
1024 assert!(resolve_selector(name).is_ok(), "{name} must resolve");
1025 }
1026 }
1027
1028 #[test]
1029 fn resolve_selector_takes_family_names() {
1030 assert_eq!(resolve_selector("loc"), Ok(Metric::Loc));
1031 assert_eq!(resolve_selector("cyclomatic"), Ok(Metric::Cyclomatic));
1032 for name in Metric::NAMES {
1033 assert_eq!(
1034 resolve_selector(name).map(|m| m.to_string()),
1035 Ok((*name).to_owned())
1036 );
1037 }
1038 }
1039
1040 /// A `list-metrics` row name selects the family that owns the row,
1041 /// so every `loc` sub-measurement selects the whole `loc` family.
1042 #[test]
1043 fn resolve_selector_maps_row_names_to_their_family() {
1044 for row in ["sloc", "ploc", "lloc", "cloc", "blank", "dloc"] {
1045 assert_eq!(resolve_selector(row), Ok(Metric::Loc), "{row}");
1046 }
1047 }
1048
1049 /// A dotted threshold id selects its family. None of these is a
1050 /// family or row name, so only the `lookup` fallback can answer.
1051 #[test]
1052 fn resolve_selector_maps_dotted_ids_to_their_family() {
1053 assert_eq!(
1054 resolve_selector("cyclomatic.modified"),
1055 Ok(Metric::Cyclomatic)
1056 );
1057 assert_eq!(resolve_selector("halstead.volume"), Ok(Metric::Halstead));
1058 assert_eq!(resolve_selector("mi.original"), Ok(Metric::Mi));
1059 assert_eq!(resolve_selector("loc.dloc"), Ok(Metric::Loc));
1060 for info in METRICS {
1061 assert_eq!(
1062 resolve_selector(info.id).map(|m| m.to_string()),
1063 Ok(info.family.to_owned()),
1064 "{}",
1065 info.id
1066 );
1067 }
1068 }
1069
1070 #[test]
1071 fn unknown_selector_suggests_the_closest_name() {
1072 let err = resolve_selector("dlocc").expect_err("not a selector");
1073 assert_eq!(err.input(), "dlocc");
1074 assert_eq!(err.suggestions(), ["dloc"]);
1075 assert_eq!(
1076 err.to_string(),
1077 "unknown metric: dlocc; did you mean `dloc`?; known metrics: abc, blank, cloc, \
1078 cognitive, cyclomatic, dloc, halstead, lloc, loc, mi, nargs, nexits, nom, npa, \
1079 npm, ploc, sloc, tokens, wmc"
1080 );
1081 }
1082
1083 /// Two names tied on closeness are both offered.
1084 #[test]
1085 fn unknown_selector_lists_tied_suggestions() {
1086 let err = resolve_selector("npz").expect_err("not a selector");
1087 assert_eq!(err.suggestions(), ["npa", "npm"]);
1088 assert!(
1089 err.to_string()
1090 .starts_with("unknown metric: npz; did you mean one of `npa`, `npm`?; known"),
1091 "{err}"
1092 );
1093 }
1094
1095 #[test]
1096 fn unknown_selector_without_a_close_name_omits_the_hint() {
1097 let err = resolve_selector("xyzq").expect_err("not a selector");
1098 assert_eq!(err.suggestions(), [] as [&str; 0]);
1099 assert_eq!(
1100 err.to_string(),
1101 format!(
1102 "unknown metric: xyzq; known metrics: {}",
1103 selector_names().join(", ")
1104 )
1105 );
1106 }
1107
1108 /// Strict lowercase, as `Metric::from_str` is: a capitalised family
1109 /// name is rejected, and the hint names the spelling that works.
1110 #[test]
1111 fn resolve_selector_is_case_sensitive() {
1112 let err = resolve_selector("Loc").expect_err("case-sensitive");
1113 assert_eq!(err.suggestions(), ["loc"]);
1114 }
1115}