Skip to main content

big_code_analysis/metrics/
mi.rs

1// Per-language metric and AST modules deliberately consume the macro-
2// generated tree-sitter token enums via `use crate::*` and `use Foo::*`
3// inside match expressions — explicit imports would list dozens of
4// variants per arm and obscure the per-language token sets that are the
5// point of these files. Allowed at the module level rather than per
6// function so the per-language impl blocks stay readable.
7#![allow(clippy::enum_glob_use, clippy::unused_self, clippy::wildcard_imports)]
8// `u64` integral metric accessors (Halstead length/vocabulary, cyclomatic
9// sum, sloc/cloc) are widened to `f64` for the MI formulas; the casts are
10// bounded by the counts they came from (#530).
11#![allow(clippy::cast_precision_loss)]
12
13use std::fmt;
14
15use super::cyclomatic;
16use super::halstead;
17use super::loc;
18
19use crate::checker::Checker;
20use crate::macros::implement_metric_trait;
21
22use crate::*;
23
24/// The `Mi` metric.
25#[derive(Default, Clone, Debug, PartialEq)]
26#[non_exhaustive]
27pub struct Stats {
28    halstead_length: f64,
29    halstead_vocabulary: f64,
30    halstead_volume: f64,
31    cyclomatic: f64,
32    sloc: f64,
33    /// Comment lines as a percentage in [0, 100] (not a ratio in [0, 1]).
34    /// Only `sei` consumes this — the SEI MI formula uses `perCM` on
35    /// the percentage scale; see issue #241.
36    comments_percentage: f64,
37}
38
39impl fmt::Display for Stats {
40    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
41        write!(
42            f,
43            "original: {}, sei: {}, visual_studio: {}",
44            self.original(),
45            self.sei(),
46            self.visual_studio()
47        )
48    }
49}
50
51impl Stats {
52    // Intentionally a no-op. MI is a derived metric: the parent space
53    // recomputes it from its merged Loc / Cyclomatic / Halstead inputs
54    // (`compute_halstead_and_mi` in `spaces/compute.rs`), so there is
55    // nothing to roll up from a child's finalized `Stats`. Combining
56    // the fields here would double-apply inputs already captured by the
57    // parent's recompute. (Same rationale as `halstead::Stats::merge`.)
58    pub(crate) fn merge(&mut self, _other: &Stats) {}
59
60    #[inline]
61    fn inputs_are_empty(&self) -> bool {
62        self.halstead_volume <= 0.0 || self.sloc <= 0.0
63    }
64
65    /// Returns the `Mi` metric calculated using the original formula.
66    ///
67    /// Its value can be negative.
68    #[inline]
69    #[must_use]
70    pub fn original(&self) -> f64 {
71        if self.inputs_are_empty() {
72            return 0.0;
73        }
74        // http://www.projectcodemeter.com/cost_estimation/help/GL_maintainability.htm
75        171.0 - 5.2 * (self.halstead_volume).ln() - 0.23 * self.cyclomatic - 16.2 * self.sloc.ln()
76    }
77
78    /// Returns the `Mi` metric calculated using the derivative formula
79    /// employed by the Software Engineering Insitute (SEI).
80    ///
81    /// Its value can be negative.
82    #[inline]
83    #[must_use]
84    pub fn sei(&self) -> f64 {
85        if self.inputs_are_empty() {
86            return 0.0;
87        }
88        // http://www.projectcodemeter.com/cost_estimation/help/GL_maintainability.htm
89        171.0 - 5.2 * self.halstead_volume.log2() - 0.23 * self.cyclomatic - 16.2 * self.sloc.log2()
90            + 50.0 * (self.comments_percentage * 2.4).sqrt().sin()
91    }
92
93    /// Returns the `Mi` metric calculated using the derivative formula
94    /// employed by Microsoft Visual Studio.
95    #[inline]
96    #[must_use]
97    pub fn visual_studio(&self) -> f64 {
98        if self.inputs_are_empty() {
99            return 0.0;
100        }
101        // http://www.projectcodemeter.com/cost_estimation/help/GL_maintainability.htm
102        let formula = 171.0
103            - 5.2 * self.halstead_volume.ln()
104            - 0.23 * self.cyclomatic
105            - 16.2 * self.sloc.ln();
106        (formula * 100.0 / 171.0).max(0.)
107    }
108}
109
110#[doc(hidden)]
111/// Per-language computation of the maintainability index.
112pub(crate) trait Mi
113where
114    Self: Checker,
115{
116    /// Walk `node` and update `stats` with this metric for the language
117    /// implementing the trait.
118    fn compute(
119        loc: &loc::Stats,
120        cyclomatic: &cyclomatic::Stats,
121        halstead: &halstead::Stats,
122        stats: &mut Stats,
123    ) {
124        stats.halstead_length = halstead.length() as f64;
125        stats.halstead_vocabulary = halstead.vocabulary() as f64;
126        stats.halstead_volume = halstead.volume();
127        stats.cyclomatic = cyclomatic.cyclomatic_sum() as f64;
128        stats.sloc = loc.sloc() as f64;
129        // The SEI Maintainability Index expects `perCM` as a percentage
130        // in [0, 100], not a ratio in [0, 1] — `50·sin(√(2.4·CM))` is
131        // nonsensical when CM is two orders of magnitude too small. See
132        // issue #241 and Welker/Oman's original MI definition.
133        stats.comments_percentage = if stats.sloc == 0.0 {
134            0.0
135        } else {
136            // Clamp to [0, 100]: a comment ratio is a percentage of
137            // source lines and cannot exceed 100%. The SEI term
138            // `50·sin(√(2.4·CM))` has no clamp of its own, so an
139            // out-of-range CM (e.g. cloc > sloc) would distort
140            // `sei` by tens of points (issue #461).
141            (loc.cloc() as f64 / stats.sloc * 100.0).clamp(0.0, 100.0)
142        };
143    }
144}
145
146// `Mi` uses the bracketed `[Trait]` arm: this expands to a bare
147// `impl Mi for X {}` which inherits `Mi::compute`'s default trait
148// method body. The default method is fully language-neutral — it
149// combines already-computed Halstead / Cyclomatic / Loc stats into
150// the three MI variants — so this list is NOT a no-op like the named-
151// arm matrices for Abc / Npa / Npm / Wmc. Audited in #188.
152implement_metric_trait!(
153    [Mi],
154    PythonCode,
155    MozjsCode,
156    JavascriptCode,
157    TypescriptCode,
158    TsxCode,
159    RustCode,
160    CppCode,
161    MozcppCode,
162    CCode,
163    ObjcCode,
164    PreprocCode,
165    CcommentCode,
166    JavaCode,
167    KotlinCode,
168    GoCode,
169    PerlCode,
170    BashCode,
171    LuaCode,
172    TclCode,
173    PhpCode,
174    CsharpCode,
175    ElixirCode,
176    RubyCode,
177    GroovyCode,
178    IrulesCode
179);
180
181#[cfg(test)]
182#[allow(
183    clippy::float_cmp,
184    clippy::cast_precision_loss,
185    clippy::cast_possible_truncation,
186    clippy::cast_sign_loss,
187    clippy::similar_names,
188    clippy::doc_markdown,
189    clippy::needless_raw_string_hashes,
190    clippy::too_many_lines
191)]
192mod tests {
193    use crate::test_support::check_metrics_only_shim;
194
195    use super::*;
196
197    // Mi's dependency closure adds Loc + Cyclomatic + Halstead — the
198    // three inputs its formula consumes. No assertion here reads them
199    // directly; without them MI would be computed from zero-valued
200    // defaults.
201    check_metrics_only_shim!(check_metrics, Mi);
202
203    #[cfg(feature = "python")]
204    #[test]
205    fn mi_empty_file() {
206        check_metrics::<PythonParser>("", "empty.py", |metric| {
207            let mi = &metric.mi;
208            assert_eq!(mi.original(), 0.0);
209            assert_eq!(mi.sei(), 0.0);
210            assert_eq!(mi.visual_studio(), 0.0);
211        });
212    }
213
214    #[cfg(feature = "python")]
215    #[test]
216    fn check_mi_metrics() {
217        // This test checks that MI metric is computed correctly, so it verifies
218        // the calculations are correct, the adopted source code is irrelevant
219        check_metrics::<PythonParser>(
220            "def f():
221                 pass",
222            "foo.py",
223            |metric| {
224                insta::assert_json_snapshot!(
225                    metric.mi,
226                    @r#"
227                {
228                  "original": 146.56146936754593,
229                  "sei": 135.94629276875443,
230                  "visual_studio": 85.70846161844791
231                }
232                "#
233                );
234            },
235        );
236    }
237
238    #[test]
239    fn mi_sei_uses_comments_as_percentage() {
240        // Regression test for #241. `Stats::comments_percentage` is stored
241        // as a percentage in [0, 100], so `sei` plugs it directly into
242        // `50·sin(√(2.4·CM))`. Constructing `Stats` directly isolates the
243        // formula from the parsing pipeline and pins the scale the SEI
244        // formula expects: `perCM` is a percentage, not a ratio. With
245        // the pre-fix ratio scaling, this assertion would fail by ~50.
246        let stats = Stats {
247            halstead_length: 4.0,
248            halstead_vocabulary: 3.0,
249            halstead_volume: 4.0 * f64::log2(3.0),
250            cyclomatic: 1.0,
251            sloc: 10.0,
252            // 50% of lines are comments — drives the sin term hard.
253            comments_percentage: 50.0,
254        };
255        // Hand-derived: 171 − 5.2·log2(V) − 0.23·G − 16.2·log2(SLOC)
256        // + 50·sin(√(2.4·50)). The fifth term equals
257        // 50·sin(√120) ≈ 50·sin(10.954) ≈ −50·0.99989… ≈ −49.99…,
258        // which only lands in this neighborhood when CM is treated
259        // as a percentage; the ratio-scaled bug would put the term
260        // near +47 instead. Asserting a tight epsilon catches a
261        // reintroduction of the ratio-vs-percentage scaling bug.
262        let expected = 171.0
263            - 5.2 * stats.halstead_volume.log2()
264            - 0.23 * stats.cyclomatic
265            - 16.2 * stats.sloc.log2()
266            + 50.0 * (2.4_f64 * 50.0).sqrt().sin();
267        let actual = stats.sei();
268        assert!(
269            (actual - expected).abs() < 1e-9,
270            "sei = {actual}, expected {expected}",
271        );
272        // Sanity check against the pre-fix (ratio) behaviour: ensure
273        // the value is nowhere near the ratio-scaled answer.
274        let buggy = 171.0
275            - 5.2 * stats.halstead_volume.log2()
276            - 0.23 * stats.cyclomatic
277            - 16.2 * stats.sloc.log2()
278            + 50.0 * (2.4_f64 * 0.5).sqrt().sin();
279        // The ratio-vs-percentage flip moves the sin term by roughly
280        // its full ±50 amplitude; pin the bound at 50.0 so a partial
281        // regression (e.g. accidentally dividing by 10 instead of by 1)
282        // still fails this check instead of slipping under a generous
283        // threshold.
284        assert!(
285            (actual - buggy).abs() > 50.0,
286            "sei should differ from the ratio-scaled value by >50; got actual={actual}, buggy={buggy}",
287        );
288    }
289
290    #[cfg(feature = "rust")]
291    #[test]
292    fn rust_mi_smoke() {
293        // Rust now derives MI from the populated Loc / Cyclomatic /
294        // Halstead trios via the default trait method. This test
295        // pins the per-function MI on a tiny straight-line function
296        // so accidental regressions in the cascade get caught.
297        check_metrics::<RustParser>("fn f() -> i32 { 1 }\n", "foo.rs", |metric| {
298            let mi = &metric.mi;
299            // expected: SLOC = 1, cyclomatic = 1 (no branches), and
300            // Halstead n1 = 4 (`fn`, `->`, `{`, `}` operators visible
301            // at unit level), n2 = 2 (`f` identifier, `1` literal).
302            // The default `Mi::compute` then folds those into the
303            // three MI variants — these numbers are produced by the
304            // populated Rust trios. Pinning them anchors the snapshot
305            // against accidental drift in the cascade.
306            assert!(mi.original() > 0.0);
307            assert!(mi.sei() > 0.0);
308            assert!(mi.visual_studio() > 0.0);
309        });
310    }
311
312    #[cfg(feature = "go")]
313    #[test]
314    fn go_mi_smoke() {
315        // Go uses the default `Mi::compute`; once Loc / Cyclomatic /
316        // Halstead are populated (they are for Go), MI is derived
317        // automatically. Pin the cascade against drift.
318        check_metrics::<GoParser>(
319            "package main\nfunc f() int { return 1 }\n",
320            "foo.go",
321            |metric| {
322                let mi = &metric.mi;
323                assert!(mi.original() > 0.0);
324                assert!(mi.sei() > 0.0);
325                assert!(mi.visual_studio() > 0.0);
326            },
327        );
328    }
329
330    #[cfg(feature = "elixir")]
331    #[test]
332    fn elixir_mi_smoke() {
333        // Elixir uses the default `Mi::compute`; with Loc / Cyclomatic
334        // / Halstead populated (and now Cognitive / Abc as well), MI
335        // derives automatically. Pin the cascade against drift.
336        check_metrics::<ElixirParser>(
337            "defmodule Foo do\n  def f(x), do: x + 1\nend\n",
338            "foo.ex",
339            |metric| {
340                let mi = &metric.mi;
341                assert!(mi.original() > 0.0);
342                assert!(mi.sei() > 0.0);
343                assert!(mi.visual_studio() > 0.0);
344            },
345        );
346    }
347
348    #[cfg(feature = "cpp")]
349    #[test]
350    fn cpp_mi_smoke() {
351        // C++ uses the default `Mi::compute`; Loc / Cyclomatic /
352        // Halstead all already populated for C++, and Abc / Npa / Npm
353        // / Wmc now contribute too. MI derives from Loc + Cyclomatic
354        // + Halstead via the default. Pin the cascade against drift.
355        check_metrics::<CppParser>(
356            "int f(int x) { if (x > 0) return 1; return 0; }",
357            "foo.cpp",
358            |metric| {
359                let mi = &metric.mi;
360                assert!(mi.original() > 0.0);
361                assert!(mi.sei() > 0.0);
362                assert!(mi.visual_studio() > 0.0);
363            },
364        );
365    }
366
367    #[cfg(feature = "javascript")]
368    #[test]
369    fn javascript_mi_smoke() {
370        // JavaScript uses the default `Mi::compute`; Loc / Cyclomatic
371        // / Halstead were already populated, and Abc / Npa / Npm /
372        // Wmc now contribute too. Pin the cascade against drift.
373        check_metrics::<JavascriptParser>(
374            "function f(x) { if (x > 0) return 1; return 0; }",
375            "foo.js",
376            |metric| {
377                let mi = &metric.mi;
378                assert!(mi.original() > 0.0);
379                assert!(mi.sei() > 0.0);
380                assert!(mi.visual_studio() > 0.0);
381            },
382        );
383    }
384
385    #[cfg(feature = "mozjs")]
386    #[test]
387    fn mozjs_mi_smoke() {
388        // Mozjs shares JavaScript's MI cascade; this is a parity pin.
389        check_metrics::<MozjsParser>(
390            "function f(x) { if (x > 0) return 1; return 0; }",
391            "foo.js",
392            |metric| {
393                let mi = &metric.mi;
394                assert!(mi.original() > 0.0);
395                assert!(mi.sei() > 0.0);
396                assert!(mi.visual_studio() > 0.0);
397            },
398        );
399    }
400
401    /// `comments_percentage` feeds the unclamped SEI term
402    /// `50·sin(√(2.4·CM))`. A degenerate `Loc` with `cloc > sloc`
403    /// (which the loc.rs fix prevents for parsed input, but which the
404    /// clamp defends regardless) must yield a `comments_percentage`
405    /// capped at exactly 100, not the ~209 the raw ratio would give
406    /// (issue #461). Here `cloc = 2`, `sloc = 1` => raw 200%. Reverting
407    /// the `.clamp(0.0, 100.0)` in `Mi::compute` makes this fail.
408    #[cfg(feature = "python")]
409    #[test]
410    fn mi_comments_percentage_clamped() {
411        // cloc = 2 (degenerate), sloc = 1 (single non-unit row) => raw
412        // comments_percentage = 200%.
413        let loc = loc::Stats::with_cloc_sloc(2, 0);
414        assert_eq!(loc.cloc(), 2);
415        assert_eq!(loc.sloc(), 1);
416
417        let cyclomatic = cyclomatic::Stats::default();
418        let halstead = halstead::Stats::default();
419        let mut mi = Stats::default();
420        PythonCode::compute(&loc, &cyclomatic, &halstead, &mut mi);
421
422        assert!(
423            (mi.comments_percentage - 100.0).abs() < f64::EPSILON,
424            "comments_percentage must clamp to 100, got {}",
425            mi.comments_percentage
426        );
427    }
428}