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 #[test]
204 fn mi_empty_file() {
205 check_metrics::<PythonParser>("", "empty.py", |metric| {
206 let mi = &metric.mi;
207 assert_eq!(mi.original(), 0.0);
208 assert_eq!(mi.sei(), 0.0);
209 assert_eq!(mi.visual_studio(), 0.0);
210 });
211 }
212
213 #[test]
214 fn check_mi_metrics() {
215 // This test checks that MI metric is computed correctly, so it verifies
216 // the calculations are correct, the adopted source code is irrelevant
217 check_metrics::<PythonParser>(
218 "def f():
219 pass",
220 "foo.py",
221 |metric| {
222 insta::assert_json_snapshot!(
223 metric.mi,
224 @r#"
225 {
226 "original": 151.2033158832232,
227 "sei": 142.64306171748976,
228 "visual_studio": 88.42299174457497
229 }
230 "#
231 );
232 },
233 );
234 }
235
236 #[test]
237 fn mi_sei_uses_comments_as_percentage() {
238 // Regression test for #241. `Stats::comments_percentage` is stored
239 // as a percentage in [0, 100], so `sei` plugs it directly into
240 // `50·sin(√(2.4·CM))`. Constructing `Stats` directly isolates the
241 // formula from the parsing pipeline and pins the scale the SEI
242 // formula expects: `perCM` is a percentage, not a ratio. With
243 // the pre-fix ratio scaling, this assertion would fail by ~50.
244 let stats = Stats {
245 halstead_length: 4.0,
246 halstead_vocabulary: 3.0,
247 halstead_volume: 4.0 * f64::log2(3.0),
248 cyclomatic: 1.0,
249 sloc: 10.0,
250 // 50% of lines are comments — drives the sin term hard.
251 comments_percentage: 50.0,
252 };
253 // Hand-derived: 171 − 5.2·log2(V) − 0.23·G − 16.2·log2(SLOC)
254 // + 50·sin(√(2.4·50)). The fifth term equals
255 // 50·sin(√120) ≈ 50·sin(10.954) ≈ −50·0.99989… ≈ −49.99…,
256 // which only lands in this neighborhood when CM is treated
257 // as a percentage; the ratio-scaled bug would put the term
258 // near +47 instead. Asserting a tight epsilon catches a
259 // reintroduction of the ratio-vs-percentage scaling bug.
260 let expected = 171.0
261 - 5.2 * stats.halstead_volume.log2()
262 - 0.23 * stats.cyclomatic
263 - 16.2 * stats.sloc.log2()
264 + 50.0 * (2.4_f64 * 50.0).sqrt().sin();
265 let actual = stats.sei();
266 assert!(
267 (actual - expected).abs() < 1e-9,
268 "sei = {actual}, expected {expected}",
269 );
270 // Sanity check against the pre-fix (ratio) behaviour: ensure
271 // the value is nowhere near the ratio-scaled answer.
272 let buggy = 171.0
273 - 5.2 * stats.halstead_volume.log2()
274 - 0.23 * stats.cyclomatic
275 - 16.2 * stats.sloc.log2()
276 + 50.0 * (2.4_f64 * 0.5).sqrt().sin();
277 // The ratio-vs-percentage flip moves the sin term by roughly
278 // its full ±50 amplitude; pin the bound at 50.0 so a partial
279 // regression (e.g. accidentally dividing by 10 instead of by 1)
280 // still fails this check instead of slipping under a generous
281 // threshold.
282 assert!(
283 (actual - buggy).abs() > 50.0,
284 "sei should differ from the ratio-scaled value by >50; got actual={actual}, buggy={buggy}",
285 );
286 }
287
288 #[test]
289 fn rust_mi_smoke() {
290 // Rust now derives MI from the populated Loc / Cyclomatic /
291 // Halstead trios via the default trait method. This test
292 // pins the per-function MI on a tiny straight-line function
293 // so accidental regressions in the cascade get caught.
294 check_metrics::<RustParser>("fn f() -> i32 { 1 }\n", "foo.rs", |metric| {
295 let mi = &metric.mi;
296 // expected: SLOC = 1, cyclomatic = 1 (no branches), and
297 // Halstead n1 = 4 (`fn`, `->`, `{`, `}` operators visible
298 // at unit level), n2 = 2 (`f` identifier, `1` literal).
299 // The default `Mi::compute` then folds those into the
300 // three MI variants — these numbers are produced by the
301 // populated Rust trios. Pinning them anchors the snapshot
302 // against accidental drift in the cascade.
303 assert!(mi.original() > 0.0);
304 assert!(mi.sei() > 0.0);
305 assert!(mi.visual_studio() > 0.0);
306 });
307 }
308
309 #[test]
310 fn go_mi_smoke() {
311 // Go uses the default `Mi::compute`; once Loc / Cyclomatic /
312 // Halstead are populated (they are for Go), MI is derived
313 // automatically. Pin the cascade against drift.
314 check_metrics::<GoParser>(
315 "package main\nfunc f() int { return 1 }\n",
316 "foo.go",
317 |metric| {
318 let mi = &metric.mi;
319 assert!(mi.original() > 0.0);
320 assert!(mi.sei() > 0.0);
321 assert!(mi.visual_studio() > 0.0);
322 },
323 );
324 }
325
326 #[test]
327 fn elixir_mi_smoke() {
328 // Elixir uses the default `Mi::compute`; with Loc / Cyclomatic
329 // / Halstead populated (and now Cognitive / Abc as well), MI
330 // derives automatically. Pin the cascade against drift.
331 check_metrics::<ElixirParser>(
332 "defmodule Foo do\n def f(x), do: x + 1\nend\n",
333 "foo.ex",
334 |metric| {
335 let mi = &metric.mi;
336 assert!(mi.original() > 0.0);
337 assert!(mi.sei() > 0.0);
338 assert!(mi.visual_studio() > 0.0);
339 },
340 );
341 }
342
343 #[test]
344 fn cpp_mi_smoke() {
345 // C++ uses the default `Mi::compute`; Loc / Cyclomatic /
346 // Halstead all already populated for C++, and Abc / Npa / Npm
347 // / Wmc now contribute too. MI derives from Loc + Cyclomatic
348 // + Halstead via the default. Pin the cascade against drift.
349 check_metrics::<CppParser>(
350 "int f(int x) { if (x > 0) return 1; return 0; }",
351 "foo.cpp",
352 |metric| {
353 let mi = &metric.mi;
354 assert!(mi.original() > 0.0);
355 assert!(mi.sei() > 0.0);
356 assert!(mi.visual_studio() > 0.0);
357 },
358 );
359 }
360
361 #[test]
362 fn javascript_mi_smoke() {
363 // JavaScript uses the default `Mi::compute`; Loc / Cyclomatic
364 // / Halstead were already populated, and Abc / Npa / Npm /
365 // Wmc now contribute too. Pin the cascade against drift.
366 check_metrics::<JavascriptParser>(
367 "function f(x) { if (x > 0) return 1; return 0; }",
368 "foo.js",
369 |metric| {
370 let mi = &metric.mi;
371 assert!(mi.original() > 0.0);
372 assert!(mi.sei() > 0.0);
373 assert!(mi.visual_studio() > 0.0);
374 },
375 );
376 }
377
378 #[test]
379 fn mozjs_mi_smoke() {
380 // Mozjs shares JavaScript's MI cascade; this is a parity pin.
381 check_metrics::<MozjsParser>(
382 "function f(x) { if (x > 0) return 1; return 0; }",
383 "foo.js",
384 |metric| {
385 let mi = &metric.mi;
386 assert!(mi.original() > 0.0);
387 assert!(mi.sei() > 0.0);
388 assert!(mi.visual_studio() > 0.0);
389 },
390 );
391 }
392
393 /// `comments_percentage` feeds the unclamped SEI term
394 /// `50·sin(√(2.4·CM))`. A degenerate `Loc` with `cloc > sloc`
395 /// (which the loc.rs fix prevents for parsed input, but which the
396 /// clamp defends regardless) must yield a `comments_percentage`
397 /// capped at exactly 100, not the ~209 the raw ratio would give
398 /// (issue #461). Here `cloc = 2`, `sloc = 1` => raw 200%. Reverting
399 /// the `.clamp(0.0, 100.0)` in `Mi::compute` makes this fail.
400 #[test]
401 fn mi_comments_percentage_clamped() {
402 // cloc = 2 (degenerate), sloc = 1 (single non-unit row) => raw
403 // comments_percentage = 200%.
404 let loc = loc::Stats::with_cloc_sloc(2, 0);
405 assert_eq!(loc.cloc(), 2);
406 assert_eq!(loc.sloc(), 1);
407
408 let cyclomatic = cyclomatic::Stats::default();
409 let halstead = halstead::Stats::default();
410 let mut mi = Stats::default();
411 PythonCode::compute(&loc, &cyclomatic, &halstead, &mut mi);
412
413 assert!(
414 (mi.comments_percentage - 100.0).abs() < f64::EPSILON,
415 "comments_percentage must clamp to 100, got {}",
416 mi.comments_percentage
417 );
418 }
419}