Skip to main content

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}