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    /// The size term `L`: `ploc`, the rows that carry code, in both
33    /// documentation modes. Comment, documentation and blank rows never
34    /// enlarge it (#1607).
35    code_lines: f64,
36    /// The SEI comment ratio `CM`, in [0, 1]: comment rows over physical
37    /// rows. By default documentation leaves both sides —
38    /// `(cloc − dloc) / (sloc − dloc_without_code)` — and with
39    /// `MetricsOptions::with_mi_charges_docs(true)` it is `cloc / sloc`.
40    /// Only `sei` consumes it.
41    comment_ratio: f64,
42}
43
44impl fmt::Display for Stats {
45    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
46        write!(
47            f,
48            "original: {}, sei: {}, visual_studio: {}",
49            self.original(),
50            self.sei(),
51            self.visual_studio()
52        )
53    }
54}
55
56impl Stats {
57    // Intentionally a no-op. MI is a derived metric: the parent space
58    // recomputes it from its merged Loc / Cyclomatic / Halstead inputs
59    // (`compute_halstead_and_mi` in `spaces/compute.rs`), so there is
60    // nothing to roll up from a child's finalized `Stats`. Combining
61    // the fields here would double-apply inputs already captured by the
62    // parent's recompute. (Same rationale as `halstead::Stats::merge`.)
63    pub(crate) fn merge(&mut self, _other: &Stats) {}
64
65    /// Returns `true` when the space holds no code to measure: its
66    /// Halstead volume or its size term is zero.
67    ///
68    /// Every variant then returns the in-band sentinel `0.0`, which
69    /// cannot be told from a genuine 0 or from `mi.visual_studio`'s
70    /// clamp. Consumers that aggregate MI must skip these spaces. The
71    /// wire/JSON value stays `0.0` in 2.x; serialising `null` is
72    /// deferred to 3.0 (#1621).
73    #[inline]
74    #[must_use]
75    pub fn is_empty(&self) -> bool {
76        self.halstead_volume <= 0.0 || self.code_lines <= 0.0
77    }
78
79    /// Returns the `Mi` metric calculated using the original formula,
80    /// `171 − 5.2·ln(V) − 0.23·G − 16.2·ln(L)` with `V` the Halstead
81    /// volume, `G` the cyclomatic sum and `L` the space's `ploc`.
82    ///
83    /// Its value can be negative.
84    #[inline]
85    #[must_use]
86    pub fn original(&self) -> f64 {
87        if self.is_empty() {
88            return 0.0;
89        }
90        // http://www.projectcodemeter.com/cost_estimation/help/GL_maintainability.htm
91        171.0
92            - 5.2 * (self.halstead_volume).ln()
93            - 0.23 * self.cyclomatic
94            - 16.2 * self.code_lines.ln()
95    }
96
97    /// Returns the `Mi` metric calculated using the derivative formula
98    /// employed by the Software Engineering Institute (SEI): the
99    /// [`original`](Self::original) value plus `50·sin(√(2.4·CM))`, with
100    /// `CM` the comment ratio in [0, 1]. The comment term rises
101    /// monotonically from 0 to `50·sin(√2.4)` ≈ 49.99, so `sei` is never
102    /// below `original` and equals it when the space has no comment.
103    ///
104    /// Its value can be negative.
105    #[inline]
106    #[must_use]
107    pub fn sei(&self) -> f64 {
108        if self.is_empty() {
109            return 0.0;
110        }
111        self.original() + 50.0 * (2.4 * self.comment_ratio).sqrt().sin()
112    }
113
114    /// Returns the `Mi` metric calculated using the derivative formula
115    /// employed by Microsoft Visual Studio: the
116    /// [`original`](Self::original) value rescaled to `[0, 100]` as
117    /// `max(0, original·100/171)`.
118    #[inline]
119    #[must_use]
120    pub fn visual_studio(&self) -> f64 {
121        // http://www.projectcodemeter.com/cost_estimation/help/GL_maintainability.htm
122        // `original()` is already the `0.0` sentinel for an empty space.
123        (self.original() * 100.0 / 171.0).max(0.)
124    }
125}
126
127#[doc(hidden)]
128/// Per-language computation of the maintainability index.
129pub(crate) trait Mi
130where
131    Self: Checker,
132{
133    /// Fold the already-computed inputs into `stats`. `charges_docs` is
134    /// `MetricsOptions::mi_charges_docs`: whether documentation rows
135    /// count toward the SEI comment ratio.
136    fn compute(
137        loc: &loc::Stats,
138        cyclomatic: &cyclomatic::Stats,
139        halstead: &halstead::Stats,
140        stats: &mut Stats,
141        charges_docs: bool,
142    ) {
143        stats.halstead_length = halstead.length() as f64;
144        stats.halstead_vocabulary = halstead.vocabulary() as f64;
145        stats.halstead_volume = halstead.volume();
146        stats.cyclomatic = cyclomatic.cyclomatic_sum() as f64;
147        // The size term is `ploc`, the rows that carry code (#1607):
148        // comments, documentation and blank rows describe the code, and
149        // none of them makes it harder to maintain. A row shared between
150        // documentation and code (`def f(): """doc"""`) is a code row and
151        // stays in.
152        stats.code_lines = loc.ploc() as f64;
153        // The SEI comment ratio `CM` in [0, 1]. Documentation rows (`dloc`:
154        // doc comments, docstrings, POD) leave both sides by default, so a
155        // documented space reads the share of working comments among its
156        // other rows (#1597); with `charges_docs` every comment row over
157        // every physical row counts. Blank rows stay in the denominator.
158        //
159        // The subtractions are exact, since `dloc <= cloc <= sloc` holds
160        // after `loc::Stats::settle_excluded_rows`; `saturating_sub` is
161        // only the release-build guard.
162        let (comment_lines, physical_lines) = if charges_docs {
163            (loc.cloc(), loc.sloc())
164        } else {
165            (
166                loc.cloc().saturating_sub(loc.dloc()),
167                loc.sloc().saturating_sub(loc.dloc_without_code()),
168            )
169        };
170        stats.comment_ratio = if physical_lines == 0 {
171            0.0
172        } else {
173            // A degenerate `Loc` (cloc > sloc) must not push `CM` past 1,
174            // where the `sin` term turns back down (#461).
175            (comment_lines as f64 / physical_lines as f64).clamp(0.0, 1.0)
176        };
177    }
178}
179
180// `Mi` uses the bracketed `[Trait]` arm: this expands to a bare
181// `impl Mi for X {}` which inherits `Mi::compute`'s default trait
182// method body. The default method is fully language-neutral — it
183// combines already-computed Halstead / Cyclomatic / Loc stats into
184// the three MI variants — so this list is NOT a no-op like the named-
185// arm matrices for Abc / Npa / Npm / Wmc. Audited in #188.
186implement_metric_trait!(
187    [Mi],
188    PythonCode,
189    MozjsCode,
190    JavascriptCode,
191    TypescriptCode,
192    TsxCode,
193    RustCode,
194    CppCode,
195    MozcppCode,
196    CCode,
197    ObjcCode,
198    PreprocCode,
199    CcommentCode,
200    JavaCode,
201    KotlinCode,
202    GoCode,
203    PerlCode,
204    BashCode,
205    LuaCode,
206    TclCode,
207    PhpCode,
208    CsharpCode,
209    ElixirCode,
210    RubyCode,
211    GroovyCode,
212    IrulesCode
213);
214
215#[cfg(test)]
216#[allow(
217    clippy::float_cmp,
218    clippy::cast_precision_loss,
219    clippy::cast_possible_truncation,
220    clippy::cast_sign_loss,
221    clippy::similar_names,
222    clippy::doc_markdown,
223    clippy::needless_raw_string_hashes,
224    clippy::too_many_lines
225)]
226mod tests {
227    use crate::test_support::check_metrics_only_shim;
228    #[cfg(any(feature = "python", all(feature = "rust", feature = "java")))]
229    use crate::test_support::metrics_verbatim;
230
231    use super::*;
232
233    // Mi's dependency closure adds Loc + Cyclomatic + Halstead — the
234    // three inputs its formula consumes. No assertion here reads them
235    // directly; without them MI would be computed from zero-valued
236    // defaults.
237    check_metrics_only_shim!(check_metrics, Mi);
238
239    /// Analyses `source` byte-for-byte with the walk restricted to MI
240    /// and its three inputs (#1127), returning the metrics so two parses
241    /// can be compared.
242    #[cfg(feature = "python")]
243    fn python_mi(source: &[u8]) -> CodeMetrics {
244        metrics_verbatim(
245            crate::LANG::Python,
246            source,
247            MetricsOptions::default().with_only(&[Metric::Mi]),
248        )
249    }
250
251    /// Analyses `source` byte-for-byte in both documentation modes:
252    /// `(default, mi_charges_docs)`.
253    #[cfg(feature = "python")]
254    fn python_modes(source: &[u8]) -> (CodeMetrics, CodeMetrics) {
255        let read = |charges_docs: bool| {
256            metrics_verbatim(
257                crate::LANG::Python,
258                source,
259                MetricsOptions::default().with_mi_charges_docs(charges_docs),
260            )
261        };
262        (read(false), read(true))
263    }
264
265    /// The documented fixture `big-code-analysis-py/tests/test_mi_charges_docs.py`
266    /// drives through the Python bindings.
267    #[cfg(feature = "python")]
268    const DOCUMENTED: &[u8] = b"def clamp(value, low, high):\n    \"\"\"Return ``value`` held within ``[low, high]``.\n\n    The bounds are inclusive and are not checked against each\n    other, so ``low > high`` returns ``high`` for every input.\n    \"\"\"\n    if value < low:\n        return low\n    if value > high:\n        return high\n    return value\n";
269
270    /// Pins a comment-free space's integer inputs — Halstead length `N`
271    /// and vocabulary `n`, the cyclomatic sum `G`, and `ploc` — and its
272    /// MI against `original` / `visual_studio` constants derived by hand
273    /// from them, with `sei` equal to `original` bit for bit (`CM = 0`).
274    #[cfg(any(
275        feature = "cpp",
276        feature = "elixir",
277        feature = "go",
278        feature = "javascript",
279        feature = "mozjs",
280        feature = "rust"
281    ))]
282    #[track_caller]
283    fn assert_comment_free_mi(
284        m: &CodeMetrics,
285        inputs: (u64, u64, u64, u64),
286        original: f64,
287        visual_studio: f64,
288    ) {
289        assert_eq!(
290            (
291                m.halstead.length(),
292                m.halstead.vocabulary(),
293                m.cyclomatic.cyclomatic_sum(),
294                m.loc.ploc()
295            ),
296            inputs,
297            "(N, n, G, ploc)"
298        );
299        assert_eq!(m.loc.cloc(), 0, "the fixture is comment-free");
300        assert!(
301            (m.mi.original() - original).abs() < 1e-9,
302            "original {} vs {original}",
303            m.mi.original()
304        );
305        assert!(
306            (m.mi.visual_studio() - visual_studio).abs() < 1e-9,
307            "visual_studio {} vs {visual_studio}",
308            m.mi.visual_studio()
309        );
310        assert_eq!(m.mi.sei().to_bits(), m.mi.original().to_bits());
311    }
312
313    #[cfg(feature = "python")]
314    #[test]
315    fn mi_empty_file() {
316        check_metrics::<PythonParser>("", "empty.py", |metric| {
317            let mi = &metric.mi;
318            assert_eq!(mi.original(), 0.0);
319            assert_eq!(mi.sei(), 0.0);
320            assert_eq!(mi.visual_studio(), 0.0);
321        });
322    }
323
324    #[test]
325    fn empty_inputs_are_empty_and_every_variant_is_the_sentinel() {
326        let mut mi = Stats::default();
327        assert!(mi.is_empty());
328        assert_eq!([mi.original(), mi.sei(), mi.visual_studio()], [0.0; 3]);
329        // Either input alone being zero is enough.
330        mi.halstead_volume = 10.0;
331        assert!(mi.is_empty(), "zero size term");
332        mi.halstead_volume = 0.0;
333        mi.code_lines = 3.0;
334        assert!(mi.is_empty(), "zero Halstead volume");
335        mi.halstead_volume = 10.0;
336        assert!(!mi.is_empty());
337        assert!(mi.original() > 0.0);
338    }
339
340    #[cfg(feature = "python")]
341    #[test]
342    fn a_trivial_function_is_not_empty() {
343        check_metrics::<PythonParser>("def f():\n    pass\n", "foo.py", |metric| {
344            assert!(!metric.mi.is_empty());
345            assert!(metric.mi.original() > 0.0);
346        });
347    }
348
349    /// An `#include`-only header has rows but no Halstead tokens, so it
350    /// is empty end to end through the parser: the R3 reproducer of
351    /// #1621, where `fstlib.h` dragged `bca report`'s average down.
352    #[cfg(feature = "cpp")]
353    #[test]
354    fn an_include_only_file_is_empty() {
355        check_metrics::<CppParser>(
356            "#include <vector>\n#include \"fst.h\"\n",
357            "fstlib.h",
358            |metric| {
359                assert_eq!(metric.loc.sloc(), 2);
360                assert!(metric.mi.is_empty());
361                assert_eq!(metric.mi.original(), 0.0);
362            },
363        );
364    }
365
366    #[cfg(feature = "python")]
367    #[test]
368    fn check_mi_metrics() {
369        // This test checks that MI metric is computed correctly, so it verifies
370        // the calculations are correct, the adopted source code is irrelevant
371        check_metrics::<PythonParser>(
372            "def f():
373                 pass",
374            "foo.py",
375            |metric| {
376                // expected: no comment row, so `sei` is `original` exactly.
377                insta::assert_json_snapshot!(
378                    metric.mi,
379                    @r#"
380                {
381                  "original": 146.56146936754593,
382                  "sei": 146.56146936754593,
383                  "visual_studio": 85.70846161844791
384                }
385                "#
386                );
387            },
388        );
389    }
390
391    /// The #1597 reproducer: the same function with and without a
392    /// docstring scores identically on all three variants, because the
393    /// docstring's rows are not `ploc` and leave both sides of the
394    /// comment ratio. Before #1597 the documented side scored lower on
395    /// every variant; the CHANGELOG entry records by how much.
396    #[cfg(feature = "python")]
397    #[test]
398    fn python_docstring_does_not_move_mi() {
399        let undocumented = b"def clamp(value, low, high):\n    if value < low:\n        return low\n    if value > high:\n        return high\n    return value\n";
400        let documented = b"def clamp(value, low, high):\n    \"\"\"Return ``value`` held within ``[low, high]``.\n\n    ``low`` and ``high`` are inclusive bounds; a value outside them is\n    replaced by the nearer bound, and a value inside is returned as it\n    is. The bounds are not checked against each other, so ``low > high``\n    returns ``high`` for every input.\n    \"\"\"\n    if value < low:\n        return low\n    if value > high:\n        return high\n    return value\n";
401        let bare = python_mi(undocumented);
402        let doc = python_mi(documented);
403        // The fixture really carries the docstring the test is about.
404        assert_eq!((doc.loc.dloc(), doc.loc.cloc(), doc.loc.sloc()), (7, 7, 13));
405        assert_eq!((bare.loc.dloc(), bare.loc.sloc()), (0, 6));
406        assert_eq!(
407            (doc.mi.original(), doc.mi.sei(), doc.mi.visual_studio()),
408            (bare.mi.original(), bare.mi.sei(), bare.mi.visual_studio()),
409            "a docstring must not move any MI variant"
410        );
411        // And the values are the undocumented file's known ones, so the
412        // equality above is not two zeros agreeing.
413        assert!((bare.mi.original() - 117.676_252_636_666_53).abs() < 1e-9);
414    }
415
416    /// `with_mi_charges_docs` moves only the SEI comment ratio (#1607):
417    /// the size term is `ploc` in both modes, so `original` and
418    /// `visual_studio` are bit-identical, while `sei` reads `cloc / sloc`
419    /// when charged and the working-comment share otherwise. The
420    /// documented fixture has no working comment, so the default ratio is
421    /// 0 and charging the docstring can only raise `sei`. The root and the
422    /// named child are both asserted: the root is finalised on the stack's
423    /// last element and a child on its pop, two paths that must both carry
424    /// the flag.
425    ///
426    /// expected: `V = 25·log2 12` ≈ 89.6241, `L = ploc = 6`, `G = 4` at the
427    /// root and 3 in `clamp`: `original = 171 − 5.2·ln V − 0.23·G −
428    /// 16.2·ln 6` ≈ 117.6763 (root) / 117.9063 (`clamp`). Charged,
429    /// `CM = cloc/sloc = 5/11` and `sei` adds `50·sin(√(12/11))` ≈ 43.2328.
430    #[cfg(feature = "python")]
431    #[test]
432    fn charging_docs_moves_only_the_sei_comment_share() {
433        fn child(root: &FuncSpace) -> &CodeMetrics {
434            &root
435                .spaces
436                .iter()
437                .find(|s| s.name.as_deref() == Some("clamp"))
438                .expect("the function space")
439                .metrics
440        }
441        let space = |charges_docs: bool| {
442            crate::test_support::space_verbatim(
443                crate::LANG::Python,
444                DOCUMENTED,
445                MetricsOptions::default().with_mi_charges_docs(charges_docs),
446            )
447        };
448        let (default, charged) = (space(false), space(true));
449        let pairs = [
450            (
451                &default.metrics,
452                &charged.metrics,
453                (4, 117.676_252_636_666_53, 160.909_070_973_313_36),
454            ),
455            (
456                child(&default),
457                child(&charged),
458                (3, 117.906_252_636_666_52, 161.139_070_973_313_32),
459            ),
460        ];
461        let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
462        for (d, c, (cyclomatic, original, charged_sei)) in pairs {
463            assert_eq!(
464                (d.loc.ploc(), d.loc.cloc(), d.loc.sloc(), d.loc.dloc()),
465                (6, 5, 11, 5),
466                "the fixture is documented"
467            );
468            assert_eq!((d.halstead.length(), d.halstead.vocabulary()), (25, 12));
469            assert_eq!(d.cyclomatic.cyclomatic_sum(), cyclomatic);
470            assert!(close(d.mi.original(), original), "{}", d.mi.original());
471            assert!(close(c.mi.sei(), charged_sei), "{}", c.mi.sei());
472            assert_eq!(d.mi.original().to_bits(), c.mi.original().to_bits());
473            assert_eq!(
474                d.mi.visual_studio().to_bits(),
475                c.mi.visual_studio().to_bits()
476            );
477            assert_eq!(d.mi.sei(), d.mi.original(), "no working comment");
478            assert!(
479                c.mi.sei() > d.mi.sei(),
480                "charged {} must exceed default {}",
481                c.mi.sei(),
482                d.mi.sei()
483            );
484        }
485    }
486
487    /// A docstring sharing its row with code leaves `ploc` holding that
488    /// row in both modes, so the size term is one row either way.
489    #[cfg(feature = "python")]
490    #[test]
491    fn a_shared_doc_code_row_sizes_the_same_in_both_modes() {
492        let source = b"def f(): \"\"\"doc\"\"\"; return 1\n";
493        let (d, c) = python_modes(source);
494        assert_eq!((d.loc.dloc(), d.loc.ploc()), (1, 1));
495        assert_eq!(d.mi.code_lines, 1.0);
496        assert_eq!(c.mi.code_lines, 1.0);
497        assert_eq!(d.mi.original(), c.mi.original());
498        assert!(d.mi.original() > 0.0, "not the empty-input sentinel");
499    }
500
501    /// Blank rows leave the size term but stay in the comment ratio's
502    /// denominator: with a comment present they dilute `CM` and lower
503    /// `sei`; without one every variant is unmoved.
504    #[cfg(feature = "python")]
505    #[test]
506    fn blank_rows_move_only_a_live_comment_share() {
507        let commented =
508            python_mi(b"def f(n):\n    # c\n    if n:\n        return 1\n    return 0\n");
509        let commented_blanks =
510            python_mi(b"def f(n):\n    # c\n\n\n    if n:\n        return 1\n    return 0\n");
511        assert_eq!(commented_blanks.loc.blank(), commented.loc.blank() + 2);
512        assert_eq!(commented.mi.original(), commented_blanks.mi.original());
513        assert_eq!(
514            commented.mi.visual_studio(),
515            commented_blanks.mi.visual_studio()
516        );
517        assert!(
518            commented_blanks.mi.sei() < commented.mi.sei(),
519            "{} must be below {}",
520            commented_blanks.mi.sei(),
521            commented.mi.sei()
522        );
523
524        let bare = python_mi(b"def f(n):\n    if n:\n        return 1\n    return 0\n");
525        let bare_blanks = python_mi(b"def f(n):\n\n\n    if n:\n        return 1\n    return 0\n");
526        assert_eq!(bare_blanks.loc.blank(), bare.loc.blank() + 2);
527        let mi = |m: &CodeMetrics| (m.mi.original(), m.mi.sei(), m.mi.visual_studio());
528        assert_eq!(mi(&bare), mi(&bare_blanks));
529        assert!(bare.mi.original() > 0.0);
530    }
531
532    /// Hand-derived values for a function with two code rows, one comment
533    /// and two blank rows, in three languages, at the root and at the
534    /// function space. The inputs are pinned as integers and the expected
535    /// MI values are constants derived outside the production formula, so
536    /// a change to it (`log2`, `sloc`, a percentage `CM`) fails here.
537    ///
538    /// expected: `L = ploc = 2`, `CM = 1/5` (one comment over five rows,
539    /// no documentation), so the SEI term is `50·sin(√0.48)` ≈ 31.9355 in
540    /// every case. `V = N·log2(n)`: Python `9·log2 8 = 27`, Rust
541    /// `12·log2 10` ≈ 39.8631, C `11·log2 9` ≈ 34.8692. `G` is 2 at the
542    /// root (unit + function) and 1 in `f`, so `f` reads 0.23 above the
543    /// root. Python root: `171 − 5.2·ln 27 − 0.46 − 16.2·ln 2` =
544    /// `171 − 17.1383 − 0.46 − 11.2290` ≈ 142.1727.
545    #[cfg(all(feature = "python", feature = "rust", feature = "c"))]
546    #[test]
547    fn mi_values_derive_from_ploc_and_the_comment_ratio() {
548        // (lang, source, (N, n), [root (original, sei, vs), f (…)])
549        type Case = (crate::LANG, &'static [u8], (u64, u64), [(f64, f64, f64); 2]);
550        let cases: [Case; 3] = [
551            (
552                crate::LANG::Python,
553                b"def f(x):\n    # note\n\n\n    return x + 1\n",
554                (9, 8),
555                [
556                    (
557                        142.172_663_971_706_38,
558                        174.108_154_504_556_7,
559                        83.141_908_755_383_85,
560                    ),
561                    (
562                        142.402_663_971_706_4,
563                        174.338_154_504_556_7,
564                        83.276_411_679_360_48,
565                    ),
566                ],
567            ),
568            (
569                crate::LANG::Rust,
570                b"fn f(x: i32) -> i32 {\n    // note\n\n\n    x + 1 }\n",
571                (12, 10),
572                [
573                    (
574                        140.146_665_193_717_27,
575                        172.082_155_726_567_58,
576                        81.957_114_148_372_67,
577                    ),
578                    (
579                        140.376_665_193_717_3,
580                        172.312_155_726_567_6,
581                        82.091_617_072_349_3,
582                    ),
583                ],
584            ),
585            (
586                crate::LANG::C,
587                b"int f(int x) {\n    // note\n\n\n    return x + 1; }\n",
588                (11, 9),
589                [
590                    (
591                        140.842_679_026_834_13,
592                        172.778_169_559_684_44,
593                        82.364_139_781_774_36,
594                    ),
595                    (
596                        141.072_679_026_834_15,
597                        173.008_169_559_684_46,
598                        82.498_642_705_750_97,
599                    ),
600                ],
601            ),
602        ];
603        let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
604        for (lang, source, (length, vocabulary), expected) in cases {
605            let root = crate::test_support::space_verbatim(lang, source, MetricsOptions::default());
606            let f = root
607                .spaces
608                .iter()
609                .find(|s| s.name.as_deref() == Some("f"))
610                .unwrap_or_else(|| panic!("{lang:?}: the function space"));
611            for ((m, cyclomatic), (original, sei, vs)) in [(&root.metrics, 2), (&f.metrics, 1)]
612                .into_iter()
613                .zip(expected)
614            {
615                assert_eq!(
616                    (m.loc.ploc(), m.loc.cloc(), m.loc.blank(), m.loc.sloc()),
617                    (2, 1, 2, 5),
618                    "{lang:?}: the fixture's rows"
619                );
620                assert_eq!(
621                    (
622                        m.halstead.length(),
623                        m.halstead.vocabulary(),
624                        m.cyclomatic.cyclomatic_sum()
625                    ),
626                    (length, vocabulary, cyclomatic),
627                    "{lang:?}: the Halstead and cyclomatic inputs"
628                );
629                assert!(
630                    close(m.mi.original(), original)
631                        && close(m.mi.sei(), sei)
632                        && close(m.mi.visual_studio(), vs),
633                    "{lang:?}: {} / {} / {} vs {original} / {sei} / {vs}",
634                    m.mi.original(),
635                    m.mi.sei(),
636                    m.mi.visual_studio()
637                );
638            }
639        }
640    }
641    /// Each mode pairs its own numerator with its own denominator. The
642    /// fixture holds a three-row docstring and one working comment in
643    /// eight rows, so the four pairings read four different ratios:
644    /// default `(4 − 3)/(8 − 3) = 1/5`, charged `4/8`, and the crossed
645    /// `1/8` and `4/5` that a swapped tuple would produce.
646    ///
647    /// expected: `V = 12·log2 9` ≈ 38.0391, `L = ploc = 4`, `G = 3` at the
648    /// root, so `original = 171 − 5.2·ln V − 0.69 − 16.2·ln 4` ≈ 128.9312;
649    /// `sei` adds `50·sin(√0.48)` ≈ 31.9355 by default and
650    /// `50·sin(√1.2)` ≈ 44.4566 charged.
651    #[cfg(feature = "python")]
652    #[test]
653    fn each_mode_pairs_its_comment_rows_with_its_own_rows() {
654        let source = b"def f(n):\n    \"\"\"Doc one.\n    Doc two.\n    \"\"\"\n    # a real comment\n    if n:\n        return 1\n    return 0\n";
655        let (d, c) = python_modes(source);
656        assert_eq!(
657            (d.loc.ploc(), d.loc.cloc(), d.loc.sloc(), d.loc.dloc()),
658            (4, 4, 8, 3)
659        );
660        assert_eq!(d.loc.dloc_without_code(), 3);
661        assert_eq!((d.halstead.length(), d.halstead.vocabulary()), (12, 9));
662        assert_eq!(d.cyclomatic.cyclomatic_sum(), 3);
663        let close = |a: f64, b: f64| (a - b).abs() < 1e-9;
664        for m in [&d, &c] {
665            assert!(close(m.mi.original(), 128.931_235_541_416_98));
666        }
667        assert!(close(d.mi.sei(), 160.866_726_074_267_3), "{}", d.mi.sei());
668        assert!(close(c.mi.sei(), 173.387_837_747_781_2), "{}", c.mi.sei());
669    }
670
671    /// The mode moves `mi.*` and nothing else: an undocumented space, a
672    /// space with only working comments, and every non-MI metric read the
673    /// same under both; the default and an explicit `false` agree.
674    #[cfg(all(feature = "python", feature = "rust"))]
675    #[test]
676    fn charging_docs_moves_only_documented_spaces_mi() {
677        let cases: &[(crate::LANG, &[u8], bool)] = &[
678            // (lang, source, carries documentation)
679            (
680                crate::LANG::Python,
681                b"def f(n):\n    # a real comment\n    if n:\n        return 1\n    return 0\n",
682                false,
683            ),
684            (
685                crate::LANG::Python,
686                b"def f(n):\n    \"\"\"Doc.\"\"\"\n    if n:\n        return 1\n    return 0\n",
687                true,
688            ),
689            (
690                crate::LANG::Rust,
691                b"/// Doc.\nfn f(x: i32) -> i32 {\n    if x > 0 { x } else { 0 }\n}\n",
692                true,
693            ),
694            (crate::LANG::Rust, b"fn f() {}\n", false),
695            (crate::LANG::Python, b"", false),
696        ];
697        for (lang, source, documented) in cases {
698            let excluded = metrics_verbatim(*lang, source, MetricsOptions::default());
699            let explicit = metrics_verbatim(
700                *lang,
701                source,
702                MetricsOptions::default().with_mi_charges_docs(false),
703            );
704            let charged = metrics_verbatim(
705                *lang,
706                source,
707                MetricsOptions::default().with_mi_charges_docs(true),
708            );
709            assert_eq!(excluded, explicit, "{lang:?}: the default is false");
710            assert_eq!((excluded.loc.dloc() > 0), *documented, "{lang:?}: fixture");
711            let mi = |m: &CodeMetrics| (m.mi.original(), m.mi.sei(), m.mi.visual_studio());
712            assert_eq!(
713                mi(&excluded) != mi(&charged),
714                *documented,
715                "{lang:?}: only documentation rows move mi"
716            );
717            let mut without_mi = charged.clone();
718            without_mi.mi = excluded.mi.clone();
719            assert_eq!(without_mi, excluded, "{lang:?}: nothing but mi moves");
720        }
721    }
722
723    #[cfg(feature = "python")]
724    #[test]
725    fn python_additional_docstrings_do_not_move_mi() {
726        for (bare, documented, rows) in [
727            (
728                "def f():\n    return 1\n",
729                "def f():\n    \"\"\"F.\"\"\"\n    \"\"\"More.\"\"\"\n    \"\"\"Still more.\"\"\"\n    return 1\n",
730                3,
731            ),
732            ("x = 1\n", "x = 1\n\"\"\"X.\"\"\"\n\"\"\"More.\"\"\"\n", 2),
733        ] {
734            let bare = python_mi(bare.as_bytes());
735            let documented = python_mi(documented.as_bytes());
736            assert_eq!(documented.loc.dloc(), rows);
737            assert!(bare.mi.original() > 0.0);
738            assert_eq!(
739                (bare.mi.original(), bare.mi.sei(), bare.mi.visual_studio()),
740                (
741                    documented.mi.original(),
742                    documented.mi.sei(),
743                    documented.mi.visual_studio(),
744                )
745            );
746        }
747    }
748
749    /// The comment ratio side of the same rule: with a real comment in
750    /// the body, `sei`'s `50·sin(√(2.4·CM))` term reads
751    /// `(cloc − dloc) / (sloc − dloc)` when no documentation row carries
752    /// code, so adding a docstring changes neither numerator nor
753    /// denominator. Subtracting the docstring from only one side would
754    /// move `CM` and with it `sei`.
755    #[cfg(feature = "python")]
756    #[test]
757    fn python_docstring_is_invisible_to_the_sei_comment_term() {
758        let commented =
759            b"def f(n):\n    # a real comment\n    if n:\n        return 1\n    return 0\n";
760        let both = b"def f(n):\n    \"\"\"Doc one.\n    Doc two.\n    \"\"\"\n    # a real comment\n    if n:\n        return 1\n    return 0\n";
761        let a = python_mi(commented);
762        let b = python_mi(both);
763        assert_eq!((a.loc.cloc(), a.loc.dloc()), (1, 0));
764        assert_eq!((b.loc.cloc(), b.loc.dloc()), (4, 3));
765        assert_eq!(
766            a.mi.sei(),
767            b.mi.sei(),
768            "sei must see the one comment row in both"
769        );
770        assert_eq!(a.mi.original(), b.mi.original());
771        // The comment term is live in this fixture: `sei` is not just the
772        // comment-free formula, so the equality above exercises it.
773        let bare = python_mi(b"def f(n):\n    if n:\n        return 1\n    return 0\n");
774        assert_ne!(
775            a.mi.sei(),
776            bare.mi.sei(),
777            "the comment row must move sei on its own"
778        );
779    }
780
781    /// A docstring that shares a row with code keeps that row in the
782    /// size term: `def f(x): """doc"""; return x` is one code row, not
783    /// none, so it scores from `ln(1)` rather than as the `0.0`
784    /// empty-input sentinel, and a docstring whose closing quote is
785    /// followed by a statement gives up only the rows it has to itself.
786    /// Each fixture's `cloc` is all docstring, so the SEI comment term
787    /// is `sin(0)` and `sei` equals `original`.
788    #[cfg(feature = "python")]
789    #[test]
790    fn python_docstring_sharing_a_code_row_keeps_the_row() {
791        // (source, docstring rows, code rows MI must size on)
792        let fixtures: [(&[u8], u64, f64); 3] = [
793            (b"def f(x): \"\"\"Return x.\"\"\"\n", 1, 1.0),
794            (b"def f(x): \"\"\"Return x.\"\"\"; return x\n", 1, 1.0),
795            (
796                b"def f(x):\n    \"\"\"Doc.\n    More.\"\"\"; y = 1\n    return x\n",
797                2,
798                3.0,
799            ),
800        ];
801        for (source, dloc, code_lines) in fixtures {
802            let m = python_mi(source);
803            assert_eq!((m.loc.dloc(), m.loc.cloc()), (dloc, dloc));
804            let volume = m.halstead.volume();
805            let cyclomatic = m.cyclomatic.cyclomatic_sum() as f64;
806            let original = 171.0 - 5.2 * volume.ln() - 0.23 * cyclomatic - 16.2 * code_lines.ln();
807            // Every comment row is docstring, so the comment share is 0.
808            let sei = original;
809            assert!(
810                (m.mi.original() - original).abs() < 1e-9 && (m.mi.sei() - sei).abs() < 1e-9,
811                "{:?}: original {} / sei {} must size on {code_lines} code rows ({original} / {sei})",
812                std::str::from_utf8(source),
813                m.mi.original(),
814                m.mi.sei()
815            );
816            assert!(m.mi.original() > 0.0, "not the empty-input sentinel");
817        }
818    }
819
820    /// Commented-out code in a triple-quoted string mid-function is not
821    /// documentation (#1597): MI charges it exactly as it charges the same
822    /// rows commented out with `#`.
823    #[cfg(feature = "python")]
824    #[test]
825    fn python_code_commented_out_in_a_string_is_charged_like_a_comment() {
826        let in_string = b"def f(n):\n    if n:\n        return 1\n    \"\"\"\n    old = n * 2\n    return old\n    \"\"\"\n    return 0\n";
827        let with_hash = b"def f(n):\n    if n:\n        return 1\n    #\n    # old = n * 2\n    # return old\n    #\n    return 0\n";
828        let a = python_mi(in_string);
829        let b = python_mi(with_hash);
830        assert_eq!(
831            (a.loc.cloc(), a.loc.dloc()),
832            (4, 0),
833            "the string is comment rows, not docs"
834        );
835        assert_eq!((b.loc.cloc(), b.loc.dloc()), (4, 0));
836        assert_eq!(
837            (a.mi.original(), a.mi.sei(), a.mi.visual_studio()),
838            (b.mi.original(), b.mi.sei(), b.mi.visual_studio()),
839            "a string-wrapped and a #-commented block must cost the same"
840        );
841    }
842
843    /// The documentation rule is language-agnostic once `dloc` is: a
844    /// Rust `///` block and a Javadoc comment leave every variant
845    /// unmoved, exactly as the Python docstring does above (#1597).
846    #[cfg(all(feature = "rust", feature = "java"))]
847    #[test]
848    fn doc_comments_do_not_move_mi_in_other_languages() {
849        let cases: &[(crate::LANG, &[u8], &[u8], u64)] = &[
850            (
851                crate::LANG::Rust,
852                b"fn f(x: i32) -> i32 {\n    if x > 0 { x } else { 0 }\n}\n",
853                b"/// Clamp `x` at zero.\n///\n/// Negative inputs collapse to zero.\nfn f(x: i32) -> i32 {\n    if x > 0 { x } else { 0 }\n}\n",
854                3,
855            ),
856            (
857                crate::LANG::Java,
858                b"class C {\n    int f(int x) {\n        if (x > 0) { return x; }\n        return 0;\n    }\n}\n",
859                b"class C {\n    /**\n     * Clamp x at zero.\n     */\n    int f(int x) {\n        if (x > 0) { return x; }\n        return 0;\n    }\n}\n",
860                3,
861            ),
862        ];
863        let mi_only = || MetricsOptions::default().with_only(&[Metric::Mi]);
864        for (lang, bare, documented, rows) in cases {
865            let a = metrics_verbatim(*lang, bare, mi_only());
866            let b = metrics_verbatim(*lang, documented, mi_only());
867            assert_eq!(
868                (b.loc.dloc(), b.loc.cloc()),
869                (*rows, *rows),
870                "{lang:?}: the doc rows"
871            );
872            assert_eq!(a.loc.dloc(), 0);
873            assert_eq!(
874                (a.mi.original(), a.mi.sei(), a.mi.visual_studio()),
875                (b.mi.original(), b.mi.sei(), b.mi.visual_studio()),
876                "{lang:?}: a doc comment must not move any MI variant"
877            );
878            assert!(
879                a.mi.original() > 0.0,
880                "{lang:?}: the parity is not two zeros agreeing"
881            );
882        }
883    }
884
885    fn ratio_stats(comment_ratio: f64) -> Stats {
886        Stats {
887            halstead_length: 4.0,
888            halstead_vocabulary: 3.0,
889            halstead_volume: 4.0 * f64::log2(3.0),
890            cyclomatic: 1.0,
891            code_lines: 10.0,
892            comment_ratio,
893        }
894    }
895
896    /// The SEI comment term `50·sin(√(2.4·CM))` on the 0–1 ratio (#1607)
897    /// rises strictly over the whole range and is bounded by its value at
898    /// CM = 1. On the 0–100 percentage scale of #241 it oscillated through
899    /// ±50, so a grid this coarse would not be monotonic.
900    #[test]
901    fn mi_sei_comment_term_is_monotonic_and_bounded() {
902        // expected: 50·sin(√(2.4·CM)) evaluated per CM, e.g. CM = 0.5 gives
903        // 50·sin(√1.2) = 50·sin(1.0954) ≈ 44.4566.
904        const CEILING: f64 = 49.988_333_226_140_14;
905        let mut previous = None;
906        for (cm, expected) in [
907            (0.0, 0.0),
908            (0.1, 23.526_792_118_944_968),
909            (0.3, 37.515_409_360_932_6),
910            (0.5, 44.456_602_206_364_21),
911            (0.7, 48.126_033_588_861_63),
912            (1.0, CEILING),
913        ] {
914            let stats = ratio_stats(cm);
915            let term = stats.sei() - stats.original();
916            assert!((term - expected).abs() < 1e-9, "CM {cm}: term {term}");
917            if let Some(p) = previous {
918                assert!(term > p, "CM {cm}: {term} must exceed {p}");
919            }
920            previous = Some(term);
921        }
922    }
923
924    /// With no comment the SEI term is `sin(0)`: `sei` is `original`,
925    /// bit for bit, which also pins `sei`'s size and volume terms to
926    /// `original`'s natural logarithms.
927    #[test]
928    fn mi_sei_equals_original_without_comments() {
929        let stats = ratio_stats(0.0);
930        assert_eq!(stats.sei(), stats.original());
931        assert!(stats.original() > 0.0);
932    }
933
934    #[cfg(feature = "rust")]
935    #[test]
936    fn rust_mi_smoke() {
937        // Rust now derives MI from the populated Loc / Cyclomatic /
938        // Halstead trios via the default trait method. This test
939        // pins the per-function MI on a tiny straight-line function
940        // so accidental regressions in the cascade get caught.
941        check_metrics::<RustParser>("fn f() -> i32 { 1 }\n", "foo.rs", |metric| {
942            // expected: N = 7, n = 7, so V = 7·log2 7 ≈ 19.6515; G = 2
943            // (unit + function); L = ploc = 1, so the size term is 0.
944            // original = 171 − 5.2·ln V − 0.46 ≈ 155.0536, and
945            // visual_studio = original·100/171 ≈ 90.6746.
946            assert_comment_free_mi(
947                &metric,
948                (7, 7, 2, 1),
949                155.053_605_022_880_67,
950                90.674_622_820_398_06,
951            );
952        });
953    }
954
955    #[cfg(feature = "go")]
956    #[test]
957    fn go_mi_smoke() {
958        // Go uses the default `Mi::compute`; once Loc / Cyclomatic /
959        // Halstead are populated (they are for Go), MI is derived
960        // automatically. Pin the cascade against drift.
961        check_metrics::<GoParser>(
962            "package main\nfunc f() int { return 1 }\n",
963            "foo.go",
964            |metric| {
965                // expected: V = 9·log2 9 ≈ 28.5293, G = 2, L = 2:
966                // 171 − 5.2·ln V − 0.46 − 16.2·ln 2 ≈ 141.8862.
967                assert_comment_free_mi(
968                    &metric,
969                    (9, 9, 2, 2),
970                    141.886_166_643_237_35,
971                    82.974_366_458_033_53,
972                );
973            },
974        );
975    }
976
977    #[cfg(feature = "elixir")]
978    #[test]
979    fn elixir_mi_smoke() {
980        // Elixir uses the default `Mi::compute`; with Loc / Cyclomatic
981        // / Halstead populated (and now Cognitive / Abc as well), MI
982        // derives automatically. Pin the cascade against drift.
983        check_metrics::<ElixirParser>(
984            "defmodule Foo do\n  def f(x), do: x + 1\nend\n",
985            "foo.ex",
986            |metric| {
987                // expected: V = 13·log2 12 ≈ 46.6045, G = 3, L = 3:
988                // 171 − 5.2·ln V − 0.69 − 16.2·ln 3 ≈ 132.5357.
989                assert_comment_free_mi(
990                    &metric,
991                    (13, 12, 3, 3),
992                    132.535_654_592_252_29,
993                    77.506_230_755_703_09,
994                );
995            },
996        );
997    }
998
999    #[cfg(feature = "cpp")]
1000    #[test]
1001    fn cpp_mi_smoke() {
1002        // C++ uses the default `Mi::compute`; Loc / Cyclomatic /
1003        // Halstead all already populated for C++, and Abc / Npa / Npm
1004        // / Wmc now contribute too. MI derives from Loc + Cyclomatic
1005        // + Halstead via the default. Pin the cascade against drift.
1006        check_metrics::<CppParser>(
1007            "int f(int x) { if (x > 0) return 1; return 0; }",
1008            "foo.cpp",
1009            |metric| {
1010                // expected: V = 17·log2 11 ≈ 58.8103, G = 3, L = 1:
1011                // 171 − 5.2·ln V − 0.69 ≈ 149.1235.
1012                assert_comment_free_mi(
1013                    &metric,
1014                    (17, 11, 3, 1),
1015                    149.123_548_232_679_84,
1016                    87.206_753_352_444_35,
1017                );
1018            },
1019        );
1020    }
1021
1022    #[cfg(feature = "javascript")]
1023    #[test]
1024    fn javascript_mi_smoke() {
1025        // JavaScript uses the default `Mi::compute`; Loc / Cyclomatic
1026        // / Halstead were already populated, and Abc / Npa / Npm /
1027        // Wmc now contribute too. Pin the cascade against drift.
1028        check_metrics::<JavascriptParser>(
1029            "function f(x) { if (x > 0) return 1; return 0; }",
1030            "foo.js",
1031            |metric| {
1032                // expected: V = 16·log2 11 ≈ 55.3509, G = 3, L = 1:
1033                // 171 − 5.2·ln V − 0.69 ≈ 149.4388.
1034                assert_comment_free_mi(
1035                    &metric,
1036                    (16, 11, 3, 1),
1037                    149.438_796_266_125_3,
1038                    87.391_108_927_558_66,
1039                );
1040            },
1041        );
1042    }
1043
1044    #[cfg(feature = "mozjs")]
1045    #[test]
1046    fn mozjs_mi_smoke() {
1047        // Mozjs shares JavaScript's MI cascade; this is a parity pin.
1048        check_metrics::<MozjsParser>(
1049            "function f(x) { if (x > 0) return 1; return 0; }",
1050            "foo.js",
1051            |metric| {
1052                // expected: the JavaScript fixture's inputs and values.
1053                assert_comment_free_mi(
1054                    &metric,
1055                    (16, 11, 3, 1),
1056                    149.438_796_266_125_3,
1057                    87.391_108_927_558_66,
1058                );
1059            },
1060        );
1061    }
1062
1063    /// `comment_ratio` feeds the SEI term `50·sin(√(2.4·CM))`, which turns
1064    /// back down past CM = 1. A degenerate `Loc` with `cloc > sloc` (which
1065    /// the loc.rs fix prevents for parsed input, but which the clamp
1066    /// defends regardless) must yield a ratio capped at exactly 1, not the
1067    /// raw 2 (issue #461). Reverting the `.clamp(0.0, 1.0)` in
1068    /// `Mi::compute` makes this fail.
1069    #[cfg(feature = "python")]
1070    #[test]
1071    fn mi_comment_ratio_clamped() {
1072        let loc = loc::Stats::with_cloc_sloc(2, 0);
1073        assert_eq!(loc.cloc(), 2);
1074        assert_eq!(loc.sloc(), 1);
1075
1076        let cyclomatic = cyclomatic::Stats::default();
1077        let halstead = halstead::Stats::default();
1078        let mut mi = Stats::default();
1079        // The clamp is on the shared tail, so one mode exercises it; the
1080        // fixture has no `dloc`, so the two modes read the same inputs.
1081        PythonCode::compute(&loc, &cyclomatic, &halstead, &mut mi, false);
1082
1083        assert_eq!(mi.comment_ratio, 1.0, "comment_ratio must clamp to 1");
1084    }
1085
1086    /// A space with no rows has a zero denominator in both modes; the
1087    /// ratio is 0, never the `NaN` of `0 / 0`.
1088    #[cfg(feature = "python")]
1089    #[test]
1090    fn mi_comment_ratio_is_zero_without_rows() {
1091        let loc = loc::Stats::default();
1092        assert_eq!(loc.sloc(), 0);
1093        for charges_docs in [false, true] {
1094            let mut mi = Stats::default();
1095            PythonCode::compute(
1096                &loc,
1097                &cyclomatic::Stats::default(),
1098                &halstead::Stats::default(),
1099                &mut mi,
1100                charges_docs,
1101            );
1102            assert_eq!(mi.comment_ratio.to_bits(), 0.0_f64.to_bits());
1103        }
1104    }
1105}