Skip to main content

big_code_analysis/metrics/
cyclomatic.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::wildcard_imports, clippy::enum_glob_use)]
8// Metric counts (token, function, branch, argument, etc.) are stored as
9// `usize` and crossed with `f64` averages, ratios, and Halstead scores
10// across the cyclomatic / MI / Halstead computations. The `usize as f64`
11// and `f64 as usize` casts are intentional and snapshot-anchored — every
12// site is bounded by the count it came from. Allowing the lints at the
13// module level keeps the metric arithmetic legible.
14#![allow(
15    clippy::cast_precision_loss,
16    clippy::cast_possible_truncation,
17    clippy::cast_sign_loss
18)]
19
20use std::fmt;
21
22use crate::checker::Checker;
23use crate::macros::implement_metric_trait;
24use crate::*;
25
26/// The `Cyclomatic` metric.
27#[derive(Debug, Clone, PartialEq)]
28#[non_exhaustive]
29pub struct Stats {
30    cyclomatic_sum: f64,
31    cyclomatic: f64,
32    /// Number of function/closure spaces in this subtree, used as the
33    /// per-function divisor for the cyclomatic averages.
34    ///
35    /// Seeded to `1` for a [`SpaceKind::Function`][crate::SpaceKind]
36    /// space and `0` otherwise (see [`Stats::note_function_space`]), then
37    /// summed across child spaces in [`Stats::merge`]. This is the
38    /// per-function divisor convention shared with `cognitive`/`exit`/
39    /// `nargs`, sourced independently of whether the `Nom` metric was
40    /// selected (#512).
41    ///
42    /// It counts the function/closure *spaces* — the spaces that each
43    /// contribute a base cyclomatic value to the sum — so it equals
44    /// `nom.total()` wherever every function and closure opens its own
45    /// space (the common case). The known exception is a closure form
46    /// that opens no space, such as a Python `lambda`: `nom` counts it
47    /// but it folds its decisions into the enclosing space, so
48    /// `function_spaces` does not count it as a separate divisor unit.
49    function_spaces: usize,
50    cyclomatic_max: f64,
51    cyclomatic_min: f64,
52    cyclomatic_modified_sum: f64,
53    cyclomatic_modified: f64,
54    cyclomatic_modified_max: f64,
55    cyclomatic_modified_min: f64,
56}
57
58impl Default for Stats {
59    fn default() -> Self {
60        Self {
61            cyclomatic_sum: 0.,
62            cyclomatic: 1.,
63            function_spaces: 0,
64            cyclomatic_max: 0.,
65            cyclomatic_min: f64::MAX,
66            cyclomatic_modified_sum: 0.,
67            cyclomatic_modified: 1.,
68            cyclomatic_modified_max: 0.,
69            cyclomatic_modified_min: f64::MAX,
70        }
71    }
72}
73
74impl fmt::Display for Stats {
75    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
76        write!(
77            f,
78            "sum: {}, average: {}, min: {}, max: {}, \
79             modified_sum: {}, modified_average: {}, modified_min: {}, modified_max: {}",
80            self.cyclomatic_sum(),
81            self.cyclomatic_average(),
82            self.cyclomatic_min(),
83            self.cyclomatic_max(),
84            self.cyclomatic_modified_sum(),
85            self.cyclomatic_modified_average(),
86            self.cyclomatic_modified_min(),
87            self.cyclomatic_modified_max(),
88        )
89    }
90}
91
92impl Stats {
93    /// Merges a second `Cyclomatic` metric into the first one
94    pub fn merge(&mut self, other: &Stats) {
95        self.cyclomatic_max = self.cyclomatic_max.max(other.cyclomatic_max);
96        self.cyclomatic_min = self.cyclomatic_min.min(other.cyclomatic_min);
97        self.cyclomatic_sum += other.cyclomatic_sum;
98        self.function_spaces += other.function_spaces;
99
100        self.cyclomatic_modified_max = self
101            .cyclomatic_modified_max
102            .max(other.cyclomatic_modified_max);
103        self.cyclomatic_modified_min = self
104            .cyclomatic_modified_min
105            .min(other.cyclomatic_modified_min);
106        self.cyclomatic_modified_sum += other.cyclomatic_modified_sum;
107    }
108
109    /// Returns the `Cyclomatic` metric value for the current space.
110    #[must_use]
111    pub fn cyclomatic(&self) -> u64 {
112        self.cyclomatic as u64
113    }
114
115    /// Returns the sum of standard cyclomatic values across all spaces.
116    #[must_use]
117    pub fn cyclomatic_sum(&self) -> u64 {
118        self.cyclomatic_sum as u64
119    }
120
121    /// Returns the average standard cyclomatic complexity.
122    ///
123    /// The divisor is the number of function/closure spaces in the
124    /// subtree (`function_spaces`), guarded with `.max(1)` via the shared
125    /// `average` helper. This is the per-function convention shared with
126    /// `cognitive`/`exit`/`nargs`; before #512 the divisor was the
127    /// per-space count `n`, which also counted classes, structs, and the
128    /// file unit and so reported a different — lower — average.
129    #[must_use]
130    pub fn cyclomatic_average(&self) -> f64 {
131        crate::metrics::average(self.cyclomatic_sum() as f64, self.function_spaces)
132    }
133
134    /// Returns the maximum standard cyclomatic complexity.
135    #[must_use]
136    pub fn cyclomatic_max(&self) -> u64 {
137        self.cyclomatic_max as u64
138    }
139
140    /// Returns the minimum standard cyclomatic complexity.
141    ///
142    /// Collapses the `f64::MAX` sentinel that `Stats::default()` plants
143    /// into `cyclomatic_min` to `0`, so a never-observed space
144    /// serializes to a meaningful number rather than `1.7976931e308`.
145    #[allow(clippy::float_cmp)]
146    #[must_use]
147    pub fn cyclomatic_min(&self) -> u64 {
148        if self.cyclomatic_min == f64::MAX {
149            0
150        } else {
151            self.cyclomatic_min as u64
152        }
153    }
154
155    /// Returns the modified cyclomatic complexity for the current space.
156    ///
157    /// Modified cyclomatic counts each switch/match/when/select container as
158    /// one decision point regardless of how many case arms it contains.  All
159    /// other branching constructs are weighted identically to standard CCN.
160    ///
161    /// Edge case: an empty switch (`switch (x) {}`) yields modified = 1
162    /// and standard = 0, so modified can exceed standard for arm-less
163    /// containers.  This matches Lizard's `-m` convention, which keys on
164    /// the switch keyword rather than the presence of arms.
165    #[must_use]
166    pub fn cyclomatic_modified(&self) -> u64 {
167        self.cyclomatic_modified as u64
168    }
169
170    /// Returns the sum of modified cyclomatic values across all spaces.
171    #[must_use]
172    pub fn cyclomatic_modified_sum(&self) -> u64 {
173        self.cyclomatic_modified_sum as u64
174    }
175
176    /// Returns the average modified cyclomatic complexity.
177    ///
178    /// Uses the same per-function divisor (`function_spaces`, guarded by
179    /// the shared `average` helper) as [`Stats::cyclomatic_average`].
180    #[must_use]
181    pub fn cyclomatic_modified_average(&self) -> f64 {
182        crate::metrics::average(self.cyclomatic_modified_sum() as f64, self.function_spaces)
183    }
184
185    /// Returns the maximum modified cyclomatic complexity.
186    #[must_use]
187    pub fn cyclomatic_modified_max(&self) -> u64 {
188        self.cyclomatic_modified_max as u64
189    }
190
191    /// Returns the minimum modified cyclomatic complexity.
192    ///
193    /// Same `f64::MAX` sentinel collapse as `cyclomatic_min`.
194    #[allow(clippy::float_cmp)]
195    #[must_use]
196    pub fn cyclomatic_modified_min(&self) -> u64 {
197        if self.cyclomatic_modified_min == f64::MAX {
198            0
199        } else {
200            self.cyclomatic_modified_min as u64
201        }
202    }
203
204    /// Marks this space as a function/closure space, seeding the
205    /// per-function divisor (`function_spaces`) with `1`.
206    ///
207    /// Called once at space construction for every
208    /// [`SpaceKind::Function`][crate::SpaceKind] space; non-function
209    /// spaces leave the seed at its `0` default. [`Stats::merge`] then
210    /// sums the seeds so each space's `function_spaces` reflects the
211    /// function/closure count of its whole subtree — independently of
212    /// the `Nom` metric (#512).
213    #[inline]
214    pub(crate) fn note_function_space(&mut self) {
215        self.function_spaces = 1;
216    }
217
218    #[inline]
219    pub(crate) fn compute_sum(&mut self) {
220        self.cyclomatic_sum += self.cyclomatic;
221        self.cyclomatic_modified_sum += self.cyclomatic_modified;
222    }
223
224    #[inline]
225    pub(crate) fn compute_minmax(&mut self) {
226        self.cyclomatic_max = self.cyclomatic_max.max(self.cyclomatic);
227        self.cyclomatic_min = self.cyclomatic_min.min(self.cyclomatic);
228        self.cyclomatic_modified_max = self.cyclomatic_modified_max.max(self.cyclomatic_modified);
229        self.cyclomatic_modified_min = self.cyclomatic_modified_min.min(self.cyclomatic_modified);
230        self.compute_sum();
231    }
232}
233
234#[doc(hidden)]
235/// Per-language computation of cyclomatic complexity.
236pub(crate) trait Cyclomatic
237where
238    Self: Checker,
239{
240    /// Walk `node` and update `stats` with this metric for the language
241    /// implementing the trait.
242    ///
243    /// `code` is the source bytes the node spans, so that languages
244    /// whose branching constructs surface as untyped `Call` nodes
245    /// (Elixir's `if`/`unless`/`for`/`while`/`with`/`case`/`cond`,
246    /// for example) can identify them by inspecting the call target's
247    /// text. Most languages discard the parameter with `_`.
248    fn compute<'a>(
249        node: &Node<'a>,
250        code: &'a [u8],
251        _ancestors: Ancestors<'a, '_>,
252        stats: &mut Stats,
253    );
254
255    /// Like [`Cyclomatic::compute`], but honors per-traversal options.
256    ///
257    /// `count_try` toggles whether Rust's `?` operator (the
258    /// `try_expression` grammar node) contributes to cyclomatic
259    /// complexity. The default body ignores `count_try` and delegates
260    /// to [`Cyclomatic::compute`], so every language whose grammar has
261    /// no `try_expression` node keeps its existing behaviour with no
262    /// per-language edit. Only [`RustCode`] overrides this to act on
263    /// the flag (#409).
264    #[inline]
265    fn compute_with_options<'a>(
266        node: &Node<'a>,
267        code: &'a [u8],
268        ancestors: Ancestors<'a, '_>,
269        stats: &mut Stats,
270        count_try: bool,
271    ) {
272        let _ = count_try;
273        Self::compute(node, code, ancestors, stats);
274    }
275}
276
277/// C-family cyclomatic: `Case` adds standard, `SwitchStatement` adds
278/// modified, and the shared branching kinds add both.  The ternary token
279/// name varies (`TernaryExpression` for JS-family, `ConditionalExpression`
280/// for Cpp), so it's a parameter.  The short-circuit operator list is
281/// also a parameter because JS-family languages include nullish
282/// coalescing (`??`, token `QMARKQMARK`) and the three compound short-
283/// circuit assignment forms `&&=` (`AMPAMPEQ`), `||=` (`PIPEPIPEEQ`),
284/// `??=` (`QMARKQMARKEQ`) on top of `&&` and `||`, while C++ has only
285/// `&&` and `||` (issues #226, #231, #248).
286///
287/// **`If` / `For` / `While` are keyword tokens in the per-language
288/// enums (e.g. `Cpp::While == "while"`), not statement nodes.** The
289/// `while` token therefore fires once inside both `WhileStatement` AND
290/// `DoStatement` (the `while` keyword of `do { … } while (…)`), and
291/// the `for` token fires once inside `ForStatement`, C++
292/// `ForRangeLoop`, Java `EnhancedForStatement`, and any other
293/// grammar-specific loop form that spells the keyword `for`. So
294/// adding the statement nodes themselves would double-count those
295/// loops — see issue #284 for the false-positive analysis. The
296/// regression tests `cpp_do_statement_counts_in_cyclomatic`,
297/// `cpp_for_range_loop_counts_in_cyclomatic`,
298/// `java_do_statement_counts_in_cyclomatic`, and
299/// `java_enhanced_for_statement_counts_in_cyclomatic` pin the
300/// correct keyword-driven counts.
301macro_rules! impl_cyclomatic_c_family {
302    ($code:ty, $lang:ident, $ternary:ident, [$($short_circuit:ident),+ $(,)?]) => {
303        impl Cyclomatic for $code {
304            fn compute<'a>(
305                node: &Node<'a>,
306                _code: &'a [u8],
307                _ancestors: Ancestors<'a, '_>,
308                stats: &mut Stats,
309            ) {
310                use $lang::*;
311                match node.kind_id().into() {
312                    Case => stats.cyclomatic += 1.,
313                    SwitchStatement => stats.cyclomatic_modified += 1.,
314                    If | For | While | Catch | $ternary $(| $short_circuit)+ => {
315                        stats.cyclomatic += 1.;
316                        stats.cyclomatic_modified += 1.;
317                    }
318                    _ => {}
319                }
320            }
321        }
322    };
323}
324
325// JS-family: include nullish coalescing (`??`) and the three compound
326// short-circuit assignments `&&=`, `||=`, `??=` as short-circuit
327// decisions in addition to `&&` and `||` (issues #226, #231, #248).
328// Each `op=` is semantically `x = x op y` — one short-circuit decision
329// edge, same as the bare operator. Cognitive parity comes from #236.
330//
331// Optional chaining `?.` is also short-circuit (it skips the rest of
332// the chain when the LHS is nullish) and adds one decision point per
333// occurrence (issue #281). The token varies across grammars:
334// JS/MozJS expose only `OptionalChain` (which IS the `?.` token in
335// those grammars), while TS/TSX expose both an `optional_chain`
336// wrapper and a child `?.` token (`QMARKDOT`); counting `QMARKDOT`
337// matches every textual `?.` exactly once in TS/TSX.
338macro_rules! impl_cyclomatic_js_family {
339    ($code:ty, $lang:ident, $opt_chain:ident) => {
340        impl_cyclomatic_c_family!(
341            $code,
342            $lang,
343            TernaryExpression,
344            [
345                AMPAMP,
346                PIPEPIPE,
347                QMARKQMARK,
348                AMPAMPEQ,
349                PIPEPIPEEQ,
350                QMARKQMARKEQ,
351                $opt_chain
352            ]
353        );
354    };
355}
356
357// Java and Groovy share the same decision-kind set for cyclomatic
358// complexity; Groovy adds `Assert` as an extra branch (its `assert`
359// keyword is a runtime check that branches on its condition,
360// matching Sonar's standard-CCN treatment). `impl_cyclomatic_java_like!`
361// emits the same match body against each enum, with an
362// `[$($extra:ident),*]` list for any language-specific decision kinds
363// (issue #300; mirrors `impl_npm_java_like!` / `impl_npa_java_like!`).
364//
365// Why a dedicated macro instead of reusing `impl_cyclomatic_c_family!`:
366// the C-family macro uses `SwitchStatement` (the wrapping node) as the
367// modified-CCN container marker, whereas Java/Groovy use the `Switch`
368// keyword token — which fires exactly once per switch (both classic
369// switch statements and Java 14+ switch expressions). Counting the
370// keyword keeps the modified-CCN tally aligned with the standard-CCN
371// `Case` arms.
372//
373// Keyword-vs-statement (issue #284): `If` / `For` / `While` here are
374// the *keyword* tokens (`Java::While == "while"`, etc.), not the
375// statement nodes. The `while` keyword therefore fires inside both
376// `WhileStatement` and `DoStatement`, and the `for` keyword fires
377// inside both `ForStatement` and `EnhancedForStatement`. The
378// grammar-specific loop forms are already counted via their inner
379// keyword tokens; listing the statement nodes here would
380// double-count. The regression tests
381// `java_do_statement_counts_in_cyclomatic`,
382// `java_enhanced_for_statement_counts_in_cyclomatic`,
383// `groovy_do_statement_counts_in_cyclomatic`, and
384// `groovy_enhanced_for_statement_counts_in_cyclomatic` pin the
385// correct keyword-driven counts.
386//
387// Groovy note: under the pinned dekobon grammar (root Cargo.toml),
388// Elvis `?:` and the safe-navigation operators `?.` / `??.` all parse
389// cleanly to dedicated nodes with real lexer tokens, so they are
390// counted as branches via the GroovyCode extra-token list below (see
391// the per-call rationale at that invocation). This differs from
392// amaanq's grammar, which emitted ERROR nodes for those constructs.
393macro_rules! impl_cyclomatic_java_like {
394    ($code:ty, $lang:ident, [$($extra:ident),* $(,)?]) => {
395        impl Cyclomatic for $code {
396            fn compute<'a>(
397                node: &Node<'a>,
398                _code: &'a [u8],
399                _ancestors: Ancestors<'a, '_>,
400                stats: &mut Stats,
401            ) {
402                use $lang::*;
403
404                match node.kind_id().into() {
405                    Case => {
406                        stats.cyclomatic += 1.;
407                    }
408                    Switch => {
409                        stats.cyclomatic_modified += 1.;
410                    }
411                    If | For | While | Catch | TernaryExpression | AMPAMP | PIPEPIPE
412                    $(| $extra)* => {
413                        stats.cyclomatic += 1.;
414                        stats.cyclomatic_modified += 1.;
415                    }
416                    _ => {}
417                }
418            }
419        }
420    };
421}
422
423// Real defaults — no executable branches. Audited in #188.
424implement_metric_trait!(Cyclomatic, PreprocCode, CcommentCode);
425
426/// Detects C# `switch_expression_arm`s whose pattern is a bare discard
427/// (`_` or `var _`) and which carry no `when` guard — the analogue of
428/// the C-family `default:` arm. Such arms must NOT contribute to
429/// standard CCN, mirroring Rust's `_ =>` and Java/C#'s `default:`
430/// treatment (lesson 11 / parity family 5). A guarded discard
431/// (`_ when g => …`) still counts because the guard introduces a
432/// non-trivial decision, matching Rust's `_ if g` rule.
433pub(crate) fn csharp_switch_expression_arm_is_bare_discard(node: &Node) -> bool {
434    use Csharp::*;
435
436    /// Classification of a `switch_expression_arm`'s pattern child.
437    /// `BareDiscard` means `_` or `var _` (the C# analogue of
438    /// `default:`); any concrete type test, constant, or composite
439    /// pattern is `NotDiscard` and still contributes to standard CCN.
440    enum PatternKind {
441        BareDiscard,
442        NotDiscard,
443    }
444
445    fn classify_pattern(child: &Node) -> PatternKind {
446        match child.kind_id().into() {
447            // `pattern` is a supertype: tree-sitter flattens it to the
448            // concrete subtype in the parse tree, so a bare `_` arm
449            // surfaces as a direct `discard` child.
450            Discard => PatternKind::BareDiscard,
451            // `var _` parses as a `declaration_pattern` with children
452            // `implicit_type` (`var`) and `discard` (`_`) rather than
453            // as a `var_pattern` — tree-sitter-c-sharp treats `var` as
454            // an implicit type designator. A `declaration_pattern`
455            // whose only named children are `implicit_type` and
456            // `discard` is therefore semantically the bare discard.
457            // A non-implicit type (`int _`) is NOT excluded — the
458            // type test is still a non-trivial decision.
459            DeclarationPattern => {
460                let mut saw_discard = false;
461                let mut saw_implicit_type = false;
462                for sub in child.children().filter(Node::is_named) {
463                    match sub.kind_id().into() {
464                        Discard => saw_discard = true,
465                        ImplicitType => saw_implicit_type = true,
466                        _ => return PatternKind::NotDiscard,
467                    }
468                }
469                if saw_discard && saw_implicit_type {
470                    PatternKind::BareDiscard
471                } else {
472                    PatternKind::NotDiscard
473                }
474            }
475            _ => PatternKind::NotDiscard,
476        }
477    }
478
479    let mut named = node.children().filter(Node::is_named);
480    let Some(pattern) = named.next() else {
481        return false;
482    };
483    let PatternKind::BareDiscard = classify_pattern(&pattern) else {
484        return false;
485    };
486    // A guarded discard (`_ when g => …`) still counts because the
487    // guard introduces a non-trivial decision, matching Rust's
488    // `_ if g` rule.
489    !named.any(|c| c.kind_id() == WhenClause)
490}
491
492/// Detects Kotlin `when_entry` nodes that are `else -> …` arms — the
493/// analogue of the C-family `default:` arm. tree-sitter-kotlin-ng
494/// attaches a `condition` field to every case-style entry; the `else`
495/// arm has no `condition` field (only an anonymous `else` keyword
496/// child). Such arms must NOT contribute to standard CCN.
497pub(crate) fn kotlin_when_entry_is_else(node: &Node) -> bool {
498    node.child_by_field_name("condition").is_none()
499}
500
501/// Detects Bash `*)` catch-all arms inside `case … esac`. Returns
502/// `true` when the case_item has exactly one `value` field whose
503/// source text is the literal `*`. Multi-value patterns (`a|b`,
504/// `*|b`) are NOT bare and still count as decisions.
505pub(crate) fn bash_case_item_is_bare_wildcard(node: &Node, code: &[u8]) -> bool {
506    // tree-sitter-bash attaches the `value` field to each alternation
507    // in the case pattern (`a|b)` produces two `value` children).
508    // Walk via a single `TreeCursor`: `field_name()` exposes the field
509    // for the current position and `goto_next_sibling()` is O(1), so
510    // total cost is linear in child count — avoiding the per-call
511    // O(i) `Node::child(i)` access that an index-based loop would
512    // pay on every iteration.
513    let mut cursor = node.as_tree_sitter().walk();
514    if !cursor.goto_first_child() {
515        return false;
516    }
517    let mut value_count = 0usize;
518    let mut sole_value_is_star = false;
519    loop {
520        if cursor.field_name() == Some("value") {
521            value_count += 1;
522            if value_count > 1 {
523                return false;
524            }
525            sole_value_is_star = cursor.node().utf8_text(code).is_ok_and(|s| s.trim() == "*");
526        }
527        if !cursor.goto_next_sibling() {
528            break;
529        }
530    }
531    value_count == 1 && sole_value_is_star
532}
533
534// Per-language `Cyclomatic` impls live in sibling modules. The `mod`
535// declarations sit after the local `macro_rules!` so textual macro
536// scoping reaches the child files (mirrors `getter.rs` and
537// `metrics::abc`).
538mod bash;
539mod c;
540mod cpp;
541mod csharp;
542mod elixir;
543mod go;
544mod groovy;
545mod irules;
546mod java;
547mod javascript;
548mod kotlin;
549mod lua;
550mod mozcpp;
551mod mozjs;
552mod objc;
553mod perl;
554mod php;
555mod python;
556mod ruby;
557mod rust;
558mod tcl;
559mod tsx;
560mod typescript;
561
562#[cfg(test)]
563#[allow(
564    clippy::float_cmp,
565    clippy::cast_precision_loss,
566    clippy::cast_possible_truncation,
567    clippy::cast_sign_loss,
568    clippy::similar_names,
569    clippy::doc_markdown,
570    clippy::needless_raw_string_hashes,
571    clippy::too_many_lines
572)]
573mod tests {
574    use crate::test_support::check_metrics_only_shim;
575
576    use super::*;
577
578    check_metrics_only_shim!(check_metrics, Cyclomatic);
579    // Two tests reconcile the cyclomatic per-function divisor against
580    // `nom.total()` (#512). Cyclomatic deliberately does *not* declare
581    // Nom as a dependency — `cyclomatic_average_per_function_without_nom_512`
582    // exists to pin that the divisor comes from the space kind, not from
583    // Nom — so those two ask for Nom explicitly.
584    check_metrics_only_shim!(check_cyclomatic_and_nom, Cyclomatic, Nom);
585
586    /// A `Stats::default()` that never sees an
587    /// observation must not leak the `f64::MAX` sentinel for
588    /// `cyclomatic_min` or `cyclomatic_modified_min`. Both getters
589    /// collapse the sentinel to `0.0` so JSON never emits
590    /// `1.7976931e308`.
591    #[test]
592    fn cyclomatic_empty_file_min_is_zero() {
593        let stats = Stats::default();
594        assert_eq!(stats.cyclomatic_min(), 0);
595        assert_eq!(stats.cyclomatic_modified_min(), 0);
596    }
597
598    /// A `Stats::default()` with no function spaces and an unguarded
599    /// divisor would divide by zero. The shared `average` helper guards
600    /// the divisor with `.max(1)`, so a function-less aggregate yields a
601    /// finite `0.0` rather than `NaN` (#512 — the guard `cognitive`/
602    /// `exit`/`nargs` already had, now applied to cyclomatic too).
603    #[test]
604    fn cyclomatic_no_function_spaces_average_is_finite() {
605        let stats = Stats::default();
606        assert_eq!(stats.cyclomatic_average(), 0.0);
607        assert_eq!(stats.cyclomatic_modified_average(), 0.0);
608    }
609
610    /// #512: the cyclomatic average divisor is now the per-function count
611    /// (`function_spaces`), reconciled with the `cognitive`/`exit`/`nargs`
612    /// convention, *not* the per-space count `n` it used before. For a
613    /// file with one class holding two methods the spaces are
614    /// `{unit, class, method, method}` (4) but only the two methods are
615    /// functions, so the divisor is 2.
616    ///
617    /// Here every function/closure opens its own space, so
618    /// `function_spaces == nom.total()` and
619    /// `cyclomatic_average == cyclomatic_sum / nom.total()` — the same
620    /// denominator `cognitive_average` divides by. (That equality can
621    /// break for closure forms that open no space, e.g. Python lambdas —
622    /// see `cyclomatic_python_lambda_divisor_excludes_spaceless_closure`.)
623    /// Before #512 the divisor was 4 (every space, base 1 each) and the
624    /// averages were two-thirds of these values (`6 / 4 == 1.5`).
625    #[test]
626    fn cyclomatic_average_is_per_function_512() {
627        check_cyclomatic_and_nom::<CsharpParser>(
628            "class A {
629                 int f(int x) { return x > 0 ? 1 : 2; }
630                 int g(int x) { return x > 0 ? 1 : 2; }
631             }",
632            "foo.cs",
633            |metric| {
634                // Sum is over every space's base 1 plus its decisions:
635                // unit(1) + class(1) + f(1 + ternary 1) + g(1 + ternary 1)
636                // = 6.
637                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 6);
638                // Divisor is the two function spaces, not the four total
639                // spaces: 6 / 2 = 3.0 (was 6 / 4 = 1.5 before #512).
640                assert_eq!(metric.cyclomatic.cyclomatic_average(), 3.0);
641                assert_eq!(metric.cyclomatic.cyclomatic_modified_average(), 3.0);
642                // Reconciliation invariant: cyclomatic divides by the same
643                // function/closure count cognitive does.
644                assert_eq!(
645                    metric.cyclomatic.cyclomatic_average(),
646                    metric.cyclomatic.cyclomatic_sum() as f64 / metric.nom.total() as f64
647                );
648                assert_eq!(metric.nom.total(), 2);
649            },
650        );
651    }
652
653    /// #512: the per-function divisor is sourced from the space kind, not
654    /// from the `Nom` metric, so selecting `cyclomatic` *alone* (which
655    /// does not pull `Nom` in via the metric-selection dependency graph)
656    /// still divides by the function count. This guards the load-bearing
657    /// "one selected metric emits exactly that metric" contract: coupling
658    /// cyclomatic to `nom` to obtain the divisor would have leaked a
659    /// `nom` block into a cyclomatic-only selection.
660    ///
661    /// `nom.total()` is `0.0` here (Nom was never computed) yet the
662    /// average is still the correct per-function `6 / 2 == 3.0` — proof
663    /// the divisor does not read `nom`.
664    #[test]
665    fn cyclomatic_average_per_function_without_nom_512() {
666        let space = crate::analyze(
667            crate::Source::new(
668                crate::LANG::Csharp,
669                b"class A {\n  int f(int x) { return x > 0 ? 1 : 2; }\n  int g(int x) { return x > 0 ? 1 : 2; }\n}",
670            )
671            .with_name(Some("foo.cs".to_owned())),
672            crate::MetricsOptions::default().with_only(&[crate::Metric::Cyclomatic]),
673        )
674        .expect("analyze must succeed on a well-formed C# fixture");
675
676        let c = &space.metrics.cyclomatic;
677        assert_eq!(c.cyclomatic_sum(), 6);
678        assert_eq!(c.cyclomatic_average(), 3.0);
679        assert_eq!(c.cyclomatic_modified_average(), 3.0);
680        // Nom was not selected, so its count stays at the zero default —
681        // the cyclomatic divisor must not depend on it.
682        assert_eq!(space.metrics.nom.total(), 0);
683    }
684
685    /// #512 edge case: a Python `lambda` is counted by `nom` (as a
686    /// closure) but opens **no** function space — it folds its decisions
687    /// into the enclosing space. So `function_spaces` counts only the
688    /// spaces that actually carry a cyclomatic value (here the single
689    /// `def`), and the cyclomatic divisor is `1`, not `nom.total()`'s `2`.
690    ///
691    /// This documents that the per-function reconciliation with
692    /// `cognitive` is exact only where every function/closure opens its
693    /// own space; for spaceless closures `function_spaces` is the divisor
694    /// that matches the spaces contributing to `cyclomatic_sum`. The
695    /// behaviour is intentional, not a bug — pinning it so a future change
696    /// to lambda space-handling is a deliberate, visible decision.
697    #[test]
698    fn cyclomatic_python_lambda_divisor_excludes_spaceless_closure() {
699        check_cyclomatic_and_nom::<PythonParser>(
700            "def f(x):\n    return x if x > 0 else -x\ng = lambda y: y if y else 0\n",
701            "p.py",
702            |metric| {
703                // sum: unit(1 + lambda's ternary 1) + f(1 + ternary 1) = 4.
704                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
705                // nom counts the lambda as a closure, so total == 2 …
706                assert_eq!(metric.nom.total(), 2);
707                // … but only the `def` opens a function space, so the
708                // cyclomatic divisor is 1: 4 / 1 = 4.0, which deliberately
709                // differs from cyclomatic_sum / nom.total() (4 / 2 = 2.0).
710                assert_eq!(metric.cyclomatic.cyclomatic_average(), 4.0);
711            },
712        );
713    }
714
715    /// A plain `if/else` must not be credited
716    /// as a loop-`else`. The `Else` arm of `impl Cyclomatic for
717    /// PythonCode` previously fired for every `else_clause` because
718    /// the old `has_ancestors` helper only verified the second
719    /// predicate; the rewritten `parent_grandparent_match` requires
720    /// the grandparent to be `for/while/try`.
721    ///
722    /// Expected: unit(1) + fn(1) + if(1) = 3. No contribution from
723    /// `else`.
724    #[test]
725    fn python_if_else_does_not_overcount_229() {
726        check_metrics::<PythonParser>(
727            "def f(x):
728    if x > 0:
729        y = 1
730    else:
731        y = 2
732",
733            "foo.py",
734            |metric| {
735                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
736                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
737                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
738                insta::assert_json_snapshot!(
739                    metric.cyclomatic,
740                    @r#"
741                {
742                  "sum": 3,
743                  "value": 1,
744                  "average": 3.0,
745                  "min": 1,
746                  "max": 2,
747                  "modified": {
748                    "sum": 3,
749                    "value": 1,
750                    "average": 3.0,
751                    "min": 1,
752                    "max": 2
753                  }
754                }
755                "#
756                );
757            },
758        );
759    }
760
761    /// Companion to #229: a chained `if/elif/else` must count one
762    /// per `if` and per `elif`, never the bare `else`.
763    ///
764    /// Expected: unit(1) + fn(1) + if(1) + elif(1) + elif(1) = 5.
765    #[test]
766    fn python_if_elif_else_chain_229() {
767        check_metrics::<PythonParser>(
768            "def f(x):
769    if x == 1:
770        return 10
771    elif x == 2:
772        return 20
773    elif x == 3:
774        return 30
775    else:
776        return 0
777",
778            "foo.py",
779            |metric| {
780                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
781                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 5);
782                assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
783            },
784        );
785    }
786
787    /// The for/else feature must still count: the `else` body runs
788    /// only when the loop completes without `break`, which is a
789    /// distinct decision point.
790    ///
791    /// Expected: unit(1) + fn(1) + for(1) + else(1) = 4.
792    #[test]
793    fn python_for_else_still_counts_229() {
794        check_metrics::<PythonParser>(
795            "def f(xs):
796    for x in xs:
797        if x < 0:
798            break
799    else:
800        return True
801    return False
802",
803            "foo.py",
804            |metric| {
805                // fn body has: for(1) + if(1) + for/else(1) = 3 over base 1 -> max = 4
806                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
807                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 5);
808                assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
809            },
810        );
811    }
812
813    /// Symmetric to for/else: while/else also runs only on normal
814    /// completion of the loop.
815    ///
816    /// Expected: unit(1) + fn(1) + while(1) + else(1) = 4.
817    #[test]
818    fn python_while_else_still_counts_229() {
819        check_metrics::<PythonParser>(
820            "def f(n):
821    while n > 0:
822        n -= 1
823    else:
824        return True
825    return False
826",
827            "foo.py",
828            |metric| {
829                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
830                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
831                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
832            },
833        );
834    }
835
836    /// try/except/else: the `else` body runs only when no exception
837    /// was raised in `try`, mirroring loop-`else` semantics. Counts
838    /// alongside the `except` arm.
839    ///
840    /// Expected: unit(1) + fn(1) + except(1) + try/else(1) = 4.
841    #[test]
842    fn python_try_except_else_counts_229() {
843        check_metrics::<PythonParser>(
844            "def f():
845    try:
846        x = risky()
847    except ValueError:
848        x = -1
849    else:
850        x = x + 1
851    return x
852",
853            "foo.py",
854            |metric| {
855                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
856                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
857                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
858            },
859        );
860    }
861
862    /// `with` is unconditional resource management, not a branch, so it
863    /// must not add to cyclomatic complexity — matching the C-family
864    /// `using` sibling and textbook McCabe. Regression test for #418.
865    ///
866    /// Expected: unit(1) + fn(1) = 2; the `with` adds nothing.
867    #[test]
868    fn python_with_is_not_a_decision_point_418() {
869        check_metrics::<PythonParser>(
870            "def f():
871    with open('a') as fp:
872        return fp.read()
873",
874            "foo.py",
875            |metric| {
876                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 2);
877                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 2);
878                assert_eq!(metric.cyclomatic.cyclomatic_max(), 1);
879            },
880        );
881    }
882
883    /// A `with` managing multiple context managers (`with a, b:`) parses
884    /// as a single `with_statement` with one `with` keyword token, so it
885    /// stays uncounted just like the single-manager form. Companion to
886    /// #418.
887    ///
888    /// Expected: unit(1) + fn(1) = 2.
889    #[test]
890    fn python_with_multiple_managers_is_not_a_decision_point_418() {
891        check_metrics::<PythonParser>(
892            "def f(a, b):
893    with a, b:
894        return 1
895",
896            "foo.py",
897            |metric| {
898                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 2);
899                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 2);
900            },
901        );
902    }
903
904    /// `async with` reuses the same `with` keyword token as plain
905    /// `with`, so dropping `With` from the decision arm stops counting
906    /// it too. Companion to #418.
907    ///
908    /// Expected: unit(1) + fn(1) = 2; neither `async` nor `with` counts.
909    #[test]
910    fn python_async_with_is_not_a_decision_point_418() {
911        check_metrics::<PythonParser>(
912            "async def f():
913    async with open('a') as fp:
914        return fp.read()
915",
916            "foo.py",
917            |metric| {
918                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 2);
919                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 2);
920            },
921        );
922    }
923
924    /// Dropping `With` must not suppress real branches *inside* a `with`
925    /// body: an `if` in the body still counts. Guards against an
926    /// over-broad fix. Companion to #418.
927    ///
928    /// Expected: unit(1) + fn(1) + if(1) = 3; the `with` adds nothing.
929    #[test]
930    fn python_with_body_branch_still_counts_418() {
931        check_metrics::<PythonParser>(
932            "def f(x):
933    with open('a') as fp:
934        if x:
935            return fp.read()
936        return None
937",
938            "foo.py",
939            |metric| {
940                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
941                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
942                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
943            },
944        );
945    }
946
947    #[test]
948    fn python_simple_function() {
949        check_metrics::<PythonParser>(
950            "def f(a, b): # +2 (+1 unit space)
951                if a and b:  # +2 (+1 and)
952                   return 1
953                if c and d: # +2 (+1 and)
954                   return 1",
955            "foo.py",
956            |metric| {
957                // nspace = 2 (func and unit)
958                insta::assert_json_snapshot!(
959                    metric.cyclomatic,
960                    @r#"
961                {
962                  "sum": 6,
963                  "value": 1,
964                  "average": 6.0,
965                  "min": 1,
966                  "max": 5,
967                  "modified": {
968                    "sum": 6,
969                    "value": 1,
970                    "average": 6.0,
971                    "min": 1,
972                    "max": 5
973                  }
974                }
975                "#
976                );
977            },
978        );
979    }
980
981    /// Python `match`/`case` (PEP 634, 3.10+): each non-bare-wildcard
982    /// arm contributes one standard decision; the containing
983    /// `match_statement` contributes one modified decision. A bare
984    /// `case _:` (no guard) is skipped, mirroring Rust's `MatchArm`
985    /// bare-wildcard filter. Regression test for #212.
986    #[test]
987    fn python_match_two_arm_wildcard() {
988        check_metrics::<PythonParser>(
989            "def f(x):
990    match x:
991        case 1:
992            return 'one'
993        case _:
994            return 'other'
995",
996            "foo.py",
997            |metric| {
998                // standard: 1 (unit) + 1 (fn) + 1 (case 1; case _ skipped) = 3
999                // modified: 1 (unit) + 1 (fn) + 1 (match_statement) = 3
1000                // function space alone holds 1 decision -> max = 2
1001                insta::assert_json_snapshot!(
1002                    metric.cyclomatic,
1003                    @r#"
1004                {
1005                  "sum": 3,
1006                  "value": 1,
1007                  "average": 3.0,
1008                  "min": 1,
1009                  "max": 2,
1010                  "modified": {
1011                    "sum": 3,
1012                    "value": 1,
1013                    "average": 3.0,
1014                    "min": 1,
1015                    "max": 2
1016                  }
1017                }
1018                "#
1019                );
1020            },
1021        );
1022    }
1023
1024    /// `case _ if guard:` still counts because the guard is an
1025    /// `if_clause` sibling on the `case_clause`, escaping the bare-
1026    /// wildcard filter. The guard's own `if` keyword token is also
1027    /// counted via the existing `If` arm (every `if` keyword in
1028    /// Python contributes a decision) — long-standing behaviour
1029    /// shared with regular `if` statements. Companion to the
1030    /// `python_match_case_guarded_wildcard_counts` test in `abc.rs`.
1031    #[test]
1032    fn python_match_guarded_wildcard_counts() {
1033        check_metrics::<PythonParser>(
1034            "def f(x):
1035    match x:
1036        case 1:
1037            return 'one'
1038        case _ if x > 0:
1039            return 'positive'
1040        case _:
1041            return 'other'
1042",
1043            "foo.py",
1044            |metric| {
1045                // standard: 1 (unit) + 1 (fn) + 1 (case 1)
1046                //         + 1 (guarded `case _ if ...` — bare-_ filter
1047                //              escaped by the guard)
1048                //         + 1 (`if` keyword inside the guard)
1049                //         = 5; bare `case _:` is filtered.
1050                // modified: 1 (unit) + 1 (fn) + 1 (match_statement)
1051                //         + 1 (`if` keyword in the guard) = 4.
1052                insta::assert_json_snapshot!(
1053                    metric.cyclomatic,
1054                    @r#"
1055                {
1056                  "sum": 5,
1057                  "value": 1,
1058                  "average": 5.0,
1059                  "min": 1,
1060                  "max": 4,
1061                  "modified": {
1062                    "sum": 4,
1063                    "value": 1,
1064                    "average": 4.0,
1065                    "min": 1,
1066                    "max": 3
1067                  }
1068                }
1069                "#
1070                );
1071            },
1072        );
1073    }
1074
1075    #[test]
1076    fn python_1_level_nesting() {
1077        check_metrics::<PythonParser>(
1078            "def f(a, b): # +2 (+1 unit space)
1079                if a:  # +1
1080                    for i in range(b):  # +1
1081                        return 1",
1082            "foo.py",
1083            |metric| {
1084                // nspace = 2 (func and unit)
1085                insta::assert_json_snapshot!(
1086                    metric.cyclomatic,
1087                    @r#"
1088                {
1089                  "sum": 4,
1090                  "value": 1,
1091                  "average": 4.0,
1092                  "min": 1,
1093                  "max": 3,
1094                  "modified": {
1095                    "sum": 4,
1096                    "value": 1,
1097                    "average": 4.0,
1098                    "min": 1,
1099                    "max": 3
1100                  }
1101                }
1102                "#
1103                );
1104            },
1105        );
1106    }
1107
1108    #[test]
1109    fn rust_1_level_nesting() {
1110        check_metrics::<RustParser>(
1111            "fn f() { // +2 (+1 unit space)
1112                 if true { // +1
1113                     match true {
1114                         true => println!(\"test\"), // +1
1115                         false => println!(\"test\"), // +1
1116                     }
1117                 }
1118             }",
1119            "foo.rs",
1120            |metric| {
1121                // nspace = 2 (func and unit)
1122                insta::assert_json_snapshot!(
1123                    metric.cyclomatic,
1124                    @r#"
1125                {
1126                  "sum": 5,
1127                  "value": 1,
1128                  "average": 5.0,
1129                  "min": 1,
1130                  "max": 4,
1131                  "modified": {
1132                    "sum": 4,
1133                    "value": 1,
1134                    "average": 4.0,
1135                    "min": 1,
1136                    "max": 3
1137                  }
1138                }
1139                "#
1140                );
1141            },
1142        );
1143    }
1144
1145    /// Modified CCN: a match with N arms counts as 1 decision, not N.
1146    /// Bare `_ =>` wildcard arm does not count toward standard CCN (same
1147    /// as C-family `default:`).
1148    #[test]
1149    fn rust_match_modified() {
1150        check_metrics::<RustParser>(
1151            "fn f(x: u8) -> &'static str { // standard: +1 (unit) +1 (fn) +2 (arms 1,2) = 4; modified: +1 (unit) +1 (fn) +1 (MatchExpr) = 3
1152                 match x {
1153                     1 => \"one\",
1154                     2 => \"two\",
1155                     _ => \"other\",
1156                 }
1157             }",
1158            "foo.rs",
1159            |metric| {
1160                insta::assert_json_snapshot!(
1161                    metric.cyclomatic,
1162                    @r#"
1163                {
1164                  "sum": 4,
1165                  "value": 1,
1166                  "average": 4.0,
1167                  "min": 1,
1168                  "max": 3,
1169                  "modified": {
1170                    "sum": 3,
1171                    "value": 1,
1172                    "average": 3.0,
1173                    "min": 1,
1174                    "max": 2
1175                  }
1176                }
1177                "#
1178                );
1179            },
1180        );
1181    }
1182
1183    // The `?` operator (TryExpression) is the configurable arm (#409).
1184    // Fixture has exactly N=3 `?` operators in a single function. With
1185    // counting on (the default) each adds +1 to both standard and
1186    // modified; with counting off they add nothing. The two runs must
1187    // therefore differ by exactly N on both sub-metrics.
1188    const RUST_TRY_FIXTURE: &str = "fn f(s: &str) -> Result<i64, std::num::ParseIntError> {
1189             let a: i64 = s.parse()?;
1190             let b: i64 = s.parse()?;
1191             let c: i64 = s.parse()?;
1192             Ok(a + b + c)
1193         }";
1194    const RUST_TRY_COUNT: u64 = 3;
1195
1196    fn rust_cyclomatic_with_try(count_try: bool) -> super::Stats {
1197        let func_space = crate::analyze(
1198            crate::Source::new(crate::LANG::Rust, RUST_TRY_FIXTURE.as_bytes())
1199                .with_name(Some("try.rs".to_owned())),
1200            crate::MetricsOptions::default().with_count_cyclomatic_try(count_try),
1201        )
1202        .expect("analyze must succeed on a well-formed Rust fixture");
1203        func_space.metrics.cyclomatic.clone()
1204    }
1205
1206    #[test]
1207    fn rust_try_toggle_differs_by_exactly_n() {
1208        let with = rust_cyclomatic_with_try(true);
1209        let without = rust_cyclomatic_with_try(false);
1210
1211        // Headline acceptance (#409): the toggle's whole effect is the N
1212        // `?` operators, on both standard and modified cyclomatic.
1213        assert_eq!(
1214            with.cyclomatic_sum() - without.cyclomatic_sum(),
1215            RUST_TRY_COUNT,
1216            "standard cyclomatic must drop by exactly N when `?` is not counted"
1217        );
1218        assert_eq!(
1219            with.cyclomatic_modified_sum() - without.cyclomatic_modified_sum(),
1220            RUST_TRY_COUNT,
1221            "modified cyclomatic must drop by exactly N when `?` is not counted"
1222        );
1223        // Guard against a no-op toggle: the two runs must actually differ.
1224        assert_ne!(with.cyclomatic_sum(), without.cyclomatic_sum());
1225    }
1226
1227    #[test]
1228    fn rust_try_default_counts() {
1229        // The default (no options) must keep counting `?`, preserving
1230        // every published metric value (#409). Equivalent to the
1231        // `count_try == true` run above.
1232        let default_path = {
1233            let func_space = crate::analyze(
1234                crate::Source::new(crate::LANG::Rust, RUST_TRY_FIXTURE.as_bytes())
1235                    .with_name(Some("try.rs".to_owned())),
1236                crate::MetricsOptions::default(),
1237            )
1238            .expect("analyze must succeed on a well-formed Rust fixture");
1239            func_space.metrics.cyclomatic.clone()
1240        };
1241        let explicit_on = rust_cyclomatic_with_try(true);
1242        assert_eq!(default_path.cyclomatic_sum(), explicit_on.cyclomatic_sum());
1243        assert_eq!(
1244            default_path.cyclomatic_modified_sum(),
1245            explicit_on.cyclomatic_modified_sum()
1246        );
1247        // unit(1) + fn(entry 1 + 3*`?` = 4) = 5 standard; modified same
1248        // shape (no match container here): 1 + 4 = 5.
1249        assert_eq!(default_path.cyclomatic_sum(), 5);
1250        assert_eq!(default_path.cyclomatic_modified_sum(), 5);
1251    }
1252
1253    /// `Cyclomatic::compute` for Rust is the trait's plain entry point,
1254    /// and the metric walk never reaches it: `compute_per_node` calls
1255    /// `compute_with_options` directly so it can pass the
1256    /// `count_cyclomatic_try` option through. The plain form is still
1257    /// the documented default for any caller that goes through the
1258    /// trait, and #409 fixes that default as "`?` counts" — so it needs
1259    /// a test of its own rather than inheriting the option tests above,
1260    /// which exercise the other entry point.
1261    ///
1262    /// Asserting only that the two forms agree would be satisfied by a
1263    /// `compute` that delegated with `false` *and* a
1264    /// `compute_with_options` that ignored the flag, so the count is
1265    /// pinned against the opted-out run as well.
1266    #[test]
1267    fn rust_compute_delegates_with_try_counting_on() {
1268        use crate::traits::ParserTrait;
1269
1270        let parser = crate::RustParser::new(
1271            RUST_TRY_FIXTURE.as_bytes().to_vec(),
1272            std::path::Path::new("try.rs"),
1273            None,
1274        );
1275        let code = parser.code();
1276
1277        let mut plain = Stats::default();
1278        let mut opted_in = Stats::default();
1279        let mut opted_out = Stats::default();
1280        for node in parser.root().preorder() {
1281            RustCode::compute(&node, code, Ancestors::unknown(), &mut plain);
1282            RustCode::compute_with_options(&node, code, Ancestors::unknown(), &mut opted_in, true);
1283            RustCode::compute_with_options(
1284                &node,
1285                code,
1286                Ancestors::unknown(),
1287                &mut opted_out,
1288                false,
1289            );
1290        }
1291
1292        assert_eq!(
1293            (plain.cyclomatic(), plain.cyclomatic_modified()),
1294            (opted_in.cyclomatic(), opted_in.cyclomatic_modified()),
1295            "`compute` must be `compute_with_options(.., true)`"
1296        );
1297        assert_eq!(
1298            plain.cyclomatic() - opted_out.cyclomatic(),
1299            RUST_TRY_COUNT,
1300            "the fixture's {RUST_TRY_COUNT} `?` operators are what the default counts"
1301        );
1302    }
1303
1304    #[test]
1305    fn c_switch() {
1306        check_metrics::<CParser>(
1307            "void f() { // +2 (+1 unit space)
1308                 switch (1) {
1309                     case 1: // +1
1310                         printf(\"one\");
1311                         break;
1312                     case 2: // +1
1313                         printf(\"two\");
1314                         break;
1315                     case 3: // +1
1316                         printf(\"three\");
1317                         break;
1318                     default:
1319                         printf(\"all\");
1320                         break;
1321                 }
1322             }",
1323            "foo.c",
1324            |metric| {
1325                // nspace = 2 (func and unit)
1326                insta::assert_json_snapshot!(
1327                    metric.cyclomatic,
1328                    @r#"
1329                {
1330                  "sum": 5,
1331                  "value": 1,
1332                  "average": 5.0,
1333                  "min": 1,
1334                  "max": 4,
1335                  "modified": {
1336                    "sum": 3,
1337                    "value": 1,
1338                    "average": 3.0,
1339                    "min": 1,
1340                    "max": 2
1341                  }
1342                }
1343                "#
1344                );
1345            },
1346        );
1347    }
1348
1349    /// Modified CCN: 3 case arms in one switch collapse to 1 decision.
1350    #[test]
1351    fn c_switch_modified() {
1352        check_metrics::<CParser>(
1353            "void f() {
1354                 switch (x) {
1355                     case 1: break;
1356                     case 2: break;
1357                     case 3: break;
1358                     default: break;
1359                 }
1360             }",
1361            "foo.c",
1362            |metric| {
1363                // standard: unit(1) + fn(1) + 3 cases = 5
1364                // modified: unit(1) + fn(1) + switch(1) = 3
1365                insta::assert_json_snapshot!(
1366                    metric.cyclomatic,
1367                    @r#"
1368                {
1369                  "sum": 5,
1370                  "value": 1,
1371                  "average": 5.0,
1372                  "min": 1,
1373                  "max": 4,
1374                  "modified": {
1375                    "sum": 3,
1376                    "value": 1,
1377                    "average": 3.0,
1378                    "min": 1,
1379                    "max": 2
1380                  }
1381                }
1382                "#
1383                );
1384            },
1385        );
1386    }
1387
1388    #[test]
1389    fn c_real_function() {
1390        check_metrics::<CParser>(
1391            "int sumOfPrimes(int max) { // +2 (+1 unit space)
1392                 int total = 0;
1393                 OUT: for (int i = 1; i <= max; ++i) { // +1
1394                   for (int j = 2; j < i; ++j) { // +1
1395                       if (i % j == 0) { // +1
1396                          continue OUT;
1397                       }
1398                   }
1399                   total += i;
1400                 }
1401                 return total;
1402            }",
1403            "foo.c",
1404            |metric| {
1405                // nspace = 2 (func and unit)
1406                insta::assert_json_snapshot!(
1407                    metric.cyclomatic,
1408                    @r#"
1409                {
1410                  "sum": 5,
1411                  "value": 1,
1412                  "average": 5.0,
1413                  "min": 1,
1414                  "max": 4,
1415                  "modified": {
1416                    "sum": 5,
1417                    "value": 1,
1418                    "average": 5.0,
1419                    "min": 1,
1420                    "max": 4
1421                  }
1422                }
1423                "#
1424                );
1425            },
1426        );
1427    }
1428
1429    #[test]
1430    fn c_unit_before() {
1431        check_metrics::<CParser>(
1432            "
1433            int a=42;
1434            if(a==42) //+2(+1 unit space)
1435            {
1436
1437            }
1438            if(a==34) //+1
1439            {
1440
1441            }
1442            int sumOfPrimes(int max) { // +1
1443                 int total = 0;
1444                 OUT: for (int i = 1; i <= max; ++i) { // +1
1445                   for (int j = 2; j < i; ++j) { // +1
1446                       if (i % j == 0) { // +1
1447                          continue OUT;
1448                       }
1449                   }
1450                   total += i;
1451                 }
1452                 return total;
1453            }",
1454            "foo.c",
1455            |metric| {
1456                // nspace = 2 (func and unit)
1457                insta::assert_json_snapshot!(
1458                    metric.cyclomatic,
1459                    @r#"
1460                {
1461                  "sum": 7,
1462                  "value": 3,
1463                  "average": 7.0,
1464                  "min": 3,
1465                  "max": 4,
1466                  "modified": {
1467                    "sum": 7,
1468                    "value": 3,
1469                    "average": 7.0,
1470                    "min": 3,
1471                    "max": 4
1472                  }
1473                }
1474                "#
1475                );
1476            },
1477        );
1478    }
1479
1480    /// Test to handle the case of min and max when merge happen before the final value of one module are set.
1481    /// In this case the min value should be 3 because the unit space has 2 branches and a complexity of 3
1482    /// while the function sumOfPrimes has a complexity of 4.
1483    #[test]
1484    fn c_unit_after() {
1485        check_metrics::<CParser>(
1486            "
1487            int sumOfPrimes(int max) { // +1
1488                 int total = 0;
1489                 OUT: for (int i = 1; i <= max; ++i) { // +1
1490                   for (int j = 2; j < i; ++j) { // +1
1491                       if (i % j == 0) { // +1
1492                          continue OUT;
1493                       }
1494                   }
1495                   total += i;
1496                 }
1497                 return total;
1498            }
1499
1500            int a=42;
1501            if(a==42) //+2(+1 unit space)
1502            {
1503
1504            }
1505            if(a==34) //+1
1506            {
1507
1508            }",
1509            "foo.c",
1510            |metric| {
1511                // nspace = 2 (func and unit)
1512                insta::assert_json_snapshot!(
1513                    metric.cyclomatic,
1514                    @r#"
1515                {
1516                  "sum": 7,
1517                  "value": 3,
1518                  "average": 7.0,
1519                  "min": 3,
1520                  "max": 4,
1521                  "modified": {
1522                    "sum": 7,
1523                    "value": 3,
1524                    "average": 7.0,
1525                    "min": 3,
1526                    "max": 4
1527                  }
1528                }
1529                "#
1530                );
1531            },
1532        );
1533    }
1534
1535    #[test]
1536    fn java_simple_class() {
1537        check_metrics::<JavaParser>(
1538            "
1539            public class Example { // +2 (+1 unit space)
1540                int a = 10;
1541                boolean b = (a > 5) ? true : false; // +1
1542                boolean c = b && true; // +1
1543
1544                public void m1() { // +1
1545                    if (a % 2 == 0) { // +1
1546                        b = b || c; // +1
1547                    }
1548                }
1549                public void m2() { // +1
1550                    while (a > 3) { // +1
1551                        m1();
1552                        a--;
1553                    }
1554                }
1555            }",
1556            "foo.java",
1557            |metric| {
1558                // nspace = 4 (unit, class and 2 methods)
1559                insta::assert_json_snapshot!(
1560                    metric.cyclomatic,
1561                    @r#"
1562                {
1563                  "sum": 9,
1564                  "value": 1,
1565                  "average": 4.5,
1566                  "min": 1,
1567                  "max": 3,
1568                  "modified": {
1569                    "sum": 9,
1570                    "value": 1,
1571                    "average": 4.5,
1572                    "min": 1,
1573                    "max": 3
1574                  }
1575                }
1576                "#
1577                );
1578            },
1579        );
1580    }
1581
1582    #[test]
1583    fn java_real_class() {
1584        check_metrics::<JavaParser>(
1585            "
1586            public class Matrix { // +2 (+1 unit space)
1587                private int[][] m = new int[5][5];
1588
1589                public void init() { // +1
1590                    for (int i = 0; i < m.length; i++) { // +1
1591                        for (int j = 0; j < m[i].length; j++) { // +1
1592                            m[i][j] = i * j;
1593                        }
1594                    }
1595                }
1596                public int compute(int i, int j) { // +1
1597                    try {
1598                        return m[i][j] / m[j][i];
1599                    } catch (ArithmeticException e) { // +1
1600                        return -1;
1601                    } catch (ArrayIndexOutOfBoundsException e) { // +1
1602                        return -2;
1603                    }
1604                }
1605                public void print(int result) { // +1
1606                    switch (result) {
1607                        case -1: // +1
1608                            System.out.println(\"Division by zero\");
1609                            break;
1610                        case -2: // +1
1611                            System.out.println(\"Wrong index number\");
1612                            break;
1613                        default:
1614                            System.out.println(\"The result is \" + result);
1615                    }
1616                }
1617            }",
1618            "foo.java",
1619            |metric| {
1620                // nspace = 5 (unit, class and 3 methods)
1621                insta::assert_json_snapshot!(
1622                    metric.cyclomatic,
1623                    @r#"
1624                {
1625                  "sum": 11,
1626                  "value": 1,
1627                  "average": 3.6666666666666665,
1628                  "min": 1,
1629                  "max": 3,
1630                  "modified": {
1631                    "sum": 10,
1632                    "value": 1,
1633                    "average": 3.3333333333333335,
1634                    "min": 1,
1635                    "max": 3
1636                  }
1637                }
1638                "#
1639                );
1640            },
1641        );
1642    }
1643
1644    /// Modified CCN: Java switch with 2 cases counts as 1 (not 2).
1645    #[test]
1646    fn java_switch_modified() {
1647        check_metrics::<JavaParser>(
1648            "public class A {
1649                public void print(int result) {
1650                    switch (result) {
1651                        case -1:
1652                            System.out.println(\"minus one\");
1653                            break;
1654                        case -2:
1655                            System.out.println(\"minus two\");
1656                            break;
1657                        default:
1658                            System.out.println(\"other\");
1659                    }
1660                }
1661            }",
1662            "foo.java",
1663            |metric| {
1664                // standard: unit(1) + class(1) + fn(1) + 2 cases = 5
1665                // modified: unit(1) + class(1) + fn(1) + switch(1) = 4
1666                insta::assert_json_snapshot!(
1667                    metric.cyclomatic,
1668                    @r#"
1669                {
1670                  "sum": 5,
1671                  "value": 1,
1672                  "average": 5.0,
1673                  "min": 1,
1674                  "max": 3,
1675                  "modified": {
1676                    "sum": 4,
1677                    "value": 1,
1678                    "average": 4.0,
1679                    "min": 1,
1680                    "max": 2
1681                  }
1682                }
1683                "#
1684                );
1685            },
1686        );
1687    }
1688
1689    #[test]
1690    fn csharp_simple_class() {
1691        check_metrics::<CsharpParser>(
1692            "public class Example {
1693                int a = 10;
1694                bool b = (a > 5) ? true : false;
1695                bool c = b && true;
1696
1697                public void M1() {
1698                    if (a % 2 == 0) {
1699                        b = b || c;
1700                    }
1701                }
1702                public void M2() {
1703                    while (a > 3) {
1704                        M1();
1705                        a--;
1706                    }
1707                }
1708            }",
1709            "foo.cs",
1710            |metric| {
1711                insta::assert_json_snapshot!(
1712                    metric.cyclomatic,
1713                    @r#"
1714                {
1715                  "sum": 9,
1716                  "value": 1,
1717                  "average": 4.5,
1718                  "min": 1,
1719                  "max": 3,
1720                  "modified": {
1721                    "sum": 9,
1722                    "value": 1,
1723                    "average": 4.5,
1724                    "min": 1,
1725                    "max": 3
1726                  }
1727                }
1728                "#
1729                );
1730            },
1731        );
1732    }
1733
1734    #[test]
1735    fn csharp_real_class() {
1736        check_metrics::<CsharpParser>(
1737            "public class Matrix {
1738                private int[,] m = new int[5, 5];
1739
1740                public void Init() {
1741                    for (int i = 0; i < 5; i++) {
1742                        for (int j = 0; j < 5; j++) {
1743                            m[i, j] = i * j;
1744                        }
1745                    }
1746                }
1747                public int Compute(int i, int j) {
1748                    try {
1749                        return m[i, j] / m[j, i];
1750                    } catch (System.DivideByZeroException) {
1751                        return -1;
1752                    } catch (System.IndexOutOfRangeException) {
1753                        return -2;
1754                    }
1755                }
1756                public void Print(int result) {
1757                    switch (result) {
1758                        case -1:
1759                            System.Console.WriteLine(\"Division by zero\");
1760                            break;
1761                        case -2:
1762                            System.Console.WriteLine(\"Wrong index number\");
1763                            break;
1764                        default:
1765                            System.Console.WriteLine(\"The result is \" + result);
1766                            break;
1767                    }
1768                }
1769            }",
1770            "foo.cs",
1771            |metric| {
1772                insta::assert_json_snapshot!(
1773                    metric.cyclomatic,
1774                    @r#"
1775                {
1776                  "sum": 11,
1777                  "value": 1,
1778                  "average": 3.6666666666666665,
1779                  "min": 1,
1780                  "max": 3,
1781                  "modified": {
1782                    "sum": 10,
1783                    "value": 1,
1784                    "average": 3.3333333333333335,
1785                    "min": 1,
1786                    "max": 3
1787                  }
1788                }
1789                "#
1790                );
1791            },
1792        );
1793    }
1794
1795    #[test]
1796    fn csharp_anonymous_method() {
1797        check_metrics::<CsharpParser>(
1798            "public class A {
1799                public void M() {
1800                    System.Action f = delegate(int x) {
1801                        if (x > 0) {
1802                            System.Console.WriteLine(x);
1803                        }
1804                    };
1805                }
1806            }",
1807            "foo.cs",
1808            |metric| {
1809                insta::assert_json_snapshot!(
1810                    metric.cyclomatic,
1811                    @r#"
1812                {
1813                  "sum": 5,
1814                  "value": 1,
1815                  "average": 2.5,
1816                  "min": 1,
1817                  "max": 2,
1818                  "modified": {
1819                    "sum": 5,
1820                    "value": 1,
1821                    "average": 2.5,
1822                    "min": 1,
1823                    "max": 2
1824                  }
1825                }
1826                "#
1827                );
1828            },
1829        );
1830    }
1831
1832    #[test]
1833    fn csharp_switch_expression_arms() {
1834        // Each non-default arm of a switch_expression contributes +1.
1835        // The discard arm `_ =>` is excluded (issue #282), mirroring
1836        // Rust's `_ =>` and Java/C#'s `default:` treatment.
1837        check_metrics::<CsharpParser>(
1838            "public class A {
1839                public string Name(int n) =>
1840                    n switch {
1841                        1 => \"one\",
1842                        2 => \"two\",
1843                        3 => \"three\",
1844                        _ => \"other\"
1845                    };
1846            }",
1847            "foo.cs",
1848            |metric| {
1849                // expected: unit(1) + class(1) + fn(base 1 + 3 explicit arms;
1850                //           `_ =>` skipped) = sum 6, max 4. modified =
1851                //           unit(1) + class(1) + fn(base 1 + switch expr 1) = 4.
1852                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 6);
1853                assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
1854                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
1855                insta::assert_json_snapshot!(
1856                    metric.cyclomatic,
1857                    @r#"
1858                {
1859                  "sum": 6,
1860                  "value": 1,
1861                  "average": 6.0,
1862                  "min": 1,
1863                  "max": 4,
1864                  "modified": {
1865                    "sum": 4,
1866                    "value": 1,
1867                    "average": 4.0,
1868                    "min": 1,
1869                    "max": 2
1870                  }
1871                }
1872                "#
1873                );
1874            },
1875        );
1876    }
1877
1878    /// Regression #282: the bare discard arm `_ =>` in a C# switch
1879    /// expression must NOT contribute to standard CCN, mirroring the
1880    /// C-family `default:` rule.
1881    #[test]
1882    fn csharp_switch_expression_discard_arm_not_counted() {
1883        check_metrics::<CsharpParser>(
1884            "public class A {
1885                public string Name(int n) =>
1886                    n switch {
1887                        1 => \"one\",
1888                        _ => \"other\"
1889                    };
1890            }",
1891            "foo.cs",
1892            |metric| {
1893                // expected: unit(1) + class(1) + fn(base 1 + 1 explicit;
1894                //           `_ =>` skipped) = 4, max 2.
1895                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
1896                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
1897            },
1898        );
1899    }
1900
1901    /// Regression #282: `var _` is also a discard pattern and must be
1902    /// excluded from standard CCN.
1903    #[test]
1904    fn csharp_switch_expression_var_underscore_not_counted() {
1905        check_metrics::<CsharpParser>(
1906            "public class A {
1907                public string Name(int n) =>
1908                    n switch {
1909                        1 => \"one\",
1910                        var _ => \"other\"
1911                    };
1912            }",
1913            "foo.cs",
1914            |metric| {
1915                // expected: unit(1) + class(1) + fn(base 1 + 1 explicit;
1916                //           `var _ =>` skipped) = 4, max 2.
1917                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
1918                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
1919            },
1920        );
1921    }
1922
1923    /// Regression #282: a guarded discard arm `_ when g => …` is NOT a
1924    /// bare wildcard — the `when` guard adds a non-trivial decision —
1925    /// so the arm still contributes one standard decision, mirroring
1926    /// Rust's `_ if g` rule.
1927    #[test]
1928    fn csharp_switch_expression_guarded_discard_still_counts() {
1929        check_metrics::<CsharpParser>(
1930            "public class A {
1931                public string Name(int n) =>
1932                    n switch {
1933                        1 => \"one\",
1934                        _ when n > 10 => \"big\",
1935                        _ => \"other\"
1936                    };
1937            }",
1938            "foo.cs",
1939            |metric| {
1940                // expected: unit(1) + class(1) + fn(base 1 + 1 explicit +
1941                //           1 guarded discard; bare `_ =>` skipped) = 5,
1942                //           max 3.
1943                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
1944                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
1945            },
1946        );
1947    }
1948
1949    /// Regression #303 / #282: a typed-discard arm `int _ =>` is NOT
1950    /// a bare discard — the type test (`predefined_type`) is a
1951    /// non-trivial decision — so the arm still contributes one
1952    /// standard decision. Locks in the
1953    /// `DeclarationPattern → _ => return NotDiscard` catch-all in
1954    /// `csharp_switch_expression_arm_is_bare_discard`.
1955    #[test]
1956    fn csharp_switch_expression_typed_discard_still_counts() {
1957        check_metrics::<CsharpParser>(
1958            "public class A {
1959                public string Name(object n) =>
1960                    n switch {
1961                        1 => \"one\",
1962                        int _ => \"int\",
1963                        _ => \"other\"
1964                    };
1965            }",
1966            "foo.cs",
1967            |metric| {
1968                // expected: unit(1) + class(1) + fn(base 1 + 1 explicit `1` +
1969                //           1 typed-discard `int _`; bare `_ =>` skipped) = 5,
1970                //           max 3.
1971                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
1972                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
1973            },
1974        );
1975    }
1976
1977    /// Regression #303 / #282: a guarded `var _ when g =>` is NOT a
1978    /// bare discard — the `when` guard adds a non-trivial decision —
1979    /// so the arm still contributes one standard decision. Exercises
1980    /// the `DeclarationPattern` arm of `classify_pattern` combined
1981    /// with the post-pattern `WhenClause` sweep.
1982    #[test]
1983    fn csharp_switch_expression_guarded_var_underscore_still_counts() {
1984        check_metrics::<CsharpParser>(
1985            "public class A {
1986                public string Name(int n) =>
1987                    n switch {
1988                        1 => \"one\",
1989                        var _ when n > 10 => \"big\",
1990                        _ => \"other\"
1991                    };
1992            }",
1993            "foo.cs",
1994            |metric| {
1995                // expected: unit(1) + class(1) + fn(base 1 + 1 explicit `1` +
1996                //           1 guarded `var _`; bare `_ =>` skipped) = 5,
1997                //           max 3.
1998                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
1999                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
2000            },
2001        );
2002    }
2003
2004    /// Modified CCN: C# switch statement with 2 cases counts as 1.
2005    #[test]
2006    fn csharp_switch_modified() {
2007        check_metrics::<CsharpParser>(
2008            "public class A {
2009                public string Describe(int n) {
2010                    switch (n) {
2011                        case 1:
2012                            return \"one\";
2013                        case 2:
2014                            return \"two\";
2015                        default:
2016                            return \"other\";
2017                    }
2018                }
2019            }",
2020            "foo.cs",
2021            |metric| {
2022                // standard: unit(1) + class(1) + fn(1) + 2 cases = 5
2023                // modified: unit(1) + class(1) + fn(1) + switch(1) = 4
2024                insta::assert_json_snapshot!(
2025                    metric.cyclomatic,
2026                    @r#"
2027                {
2028                  "sum": 5,
2029                  "value": 1,
2030                  "average": 5.0,
2031                  "min": 1,
2032                  "max": 3,
2033                  "modified": {
2034                    "sum": 4,
2035                    "value": 1,
2036                    "average": 4.0,
2037                    "min": 1,
2038                    "max": 2
2039                  }
2040                }
2041                "#
2042                );
2043            },
2044        );
2045    }
2046
2047    #[test]
2048    fn csharp_null_coalescing_and_conditional_access() {
2049        // Each `??` and `?.` is +1 cyclomatic.
2050        check_metrics::<CsharpParser>(
2051            "public class A {
2052                public int? Get(string s, A b) {
2053                    return s?.Length ?? b?.Get(null, null) ?? 0;
2054                }
2055            }",
2056            "foo.cs",
2057            |metric| {
2058                insta::assert_json_snapshot!(
2059                    metric.cyclomatic,
2060                    @r#"
2061                {
2062                  "sum": 7,
2063                  "value": 1,
2064                  "average": 7.0,
2065                  "min": 1,
2066                  "max": 5,
2067                  "modified": {
2068                    "sum": 7,
2069                    "value": 1,
2070                    "average": 7.0,
2071                    "min": 1,
2072                    "max": 5
2073                  }
2074                }
2075                "#
2076                );
2077            },
2078        );
2079    }
2080
2081    #[test]
2082    fn javascript_simple_function() {
2083        check_metrics::<JavascriptParser>(
2084            "function f(a, b) { // +2 (+1 unit space)
2085                 if (a) { // +1
2086                     return a;
2087                 } else if (b) { // +1
2088                     return b;
2089                 }
2090                 return 0;
2091             }",
2092            "foo.js",
2093            |metric| {
2094                insta::assert_json_snapshot!(
2095                    metric.cyclomatic,
2096                    @r#"
2097                {
2098                  "sum": 4,
2099                  "value": 1,
2100                  "average": 4.0,
2101                  "min": 1,
2102                  "max": 3,
2103                  "modified": {
2104                    "sum": 4,
2105                    "value": 1,
2106                    "average": 4.0,
2107                    "min": 1,
2108                    "max": 3
2109                  }
2110                }
2111                "#
2112                );
2113            },
2114        );
2115    }
2116
2117    #[test]
2118    fn javascript_switch() {
2119        check_metrics::<JavascriptParser>(
2120            "function f() { // +2 (+1 unit space)
2121                 switch (x) {
2122                     case 1: // +1
2123                         console.log(\"one\");
2124                         break;
2125                     case 2: // +1
2126                         console.log(\"two\");
2127                         break;
2128                     case 3: // +1
2129                         console.log(\"three\");
2130                         break;
2131                     default:
2132                         console.log(\"other\");
2133                         break;
2134                 }
2135             }",
2136            "foo.js",
2137            |metric| {
2138                insta::assert_json_snapshot!(
2139                    metric.cyclomatic,
2140                    @r#"
2141                {
2142                  "sum": 5,
2143                  "value": 1,
2144                  "average": 5.0,
2145                  "min": 1,
2146                  "max": 4,
2147                  "modified": {
2148                    "sum": 3,
2149                    "value": 1,
2150                    "average": 3.0,
2151                    "min": 1,
2152                    "max": 2
2153                  }
2154                }
2155                "#
2156                );
2157            },
2158        );
2159    }
2160
2161    /// Modified CCN: JS switch with 3 cases collapses to 1.
2162    #[test]
2163    fn javascript_switch_modified() {
2164        check_metrics::<JavascriptParser>(
2165            "function f(x) {
2166                 switch (x) {
2167                     case 1: return 'one';
2168                     case 2: return 'two';
2169                     case 3: return 'three';
2170                 }
2171             }",
2172            "foo.js",
2173            |metric| {
2174                // standard: unit(1) + fn(1) + 3 cases = 5
2175                // modified: unit(1) + fn(1) + switch(1) = 3
2176                insta::assert_json_snapshot!(
2177                    metric.cyclomatic,
2178                    @r#"
2179                {
2180                  "sum": 5,
2181                  "value": 1,
2182                  "average": 5.0,
2183                  "min": 1,
2184                  "max": 4,
2185                  "modified": {
2186                    "sum": 3,
2187                    "value": 1,
2188                    "average": 3.0,
2189                    "min": 1,
2190                    "max": 2
2191                  }
2192                }
2193                "#
2194                );
2195            },
2196        );
2197    }
2198
2199    #[test]
2200    fn go_simple_function() {
2201        check_metrics::<GoParser>(
2202            "package main
2203            func f() {}",
2204            "foo.go",
2205            |metric| {
2206                // nspace = 2 (file unit + func), each base 1.
2207                insta::assert_json_snapshot!(
2208                    metric.cyclomatic,
2209                    @r#"
2210                {
2211                  "sum": 2,
2212                  "value": 1,
2213                  "average": 2.0,
2214                  "min": 1,
2215                  "max": 1,
2216                  "modified": {
2217                    "sum": 2,
2218                    "value": 1,
2219                    "average": 2.0,
2220                    "min": 1,
2221                    "max": 1
2222                  }
2223                }
2224                "#
2225                );
2226            },
2227        );
2228    }
2229
2230    #[test]
2231    fn go_if_else() {
2232        check_metrics::<GoParser>(
2233            "package main
2234            func f(x bool) { // +2 (+1 unit)
2235                if x { // +1
2236                } else {
2237                }
2238            }",
2239            "foo.go",
2240            |metric| {
2241                // `else` clause attaches to the same if_statement node and is
2242                // not counted again.
2243                insta::assert_json_snapshot!(
2244                    metric.cyclomatic,
2245                    @r#"
2246                {
2247                  "sum": 3,
2248                  "value": 1,
2249                  "average": 3.0,
2250                  "min": 1,
2251                  "max": 2,
2252                  "modified": {
2253                    "sum": 3,
2254                    "value": 1,
2255                    "average": 3.0,
2256                    "min": 1,
2257                    "max": 2
2258                  }
2259                }
2260                "#
2261                );
2262            },
2263        );
2264    }
2265
2266    #[test]
2267    fn go_else_if_chain() {
2268        check_metrics::<GoParser>(
2269            "package main
2270            func f(x int) { // +2 (+1 unit)
2271                if x > 0 { // +1
2272                } else if x < 0 { // +1 (nested if_statement)
2273                } else if x == 0 { // +1 (nested if_statement)
2274                } else {
2275                }
2276            }",
2277            "foo.go",
2278            |metric| {
2279                // tree-sitter-go represents `else if` as a nested
2280                // if_statement under the parent's `else` clause; each nested
2281                // if contributes +1.
2282                insta::assert_json_snapshot!(
2283                    metric.cyclomatic,
2284                    @r#"
2285                {
2286                  "sum": 5,
2287                  "value": 1,
2288                  "average": 5.0,
2289                  "min": 1,
2290                  "max": 4,
2291                  "modified": {
2292                    "sum": 5,
2293                    "value": 1,
2294                    "average": 5.0,
2295                    "min": 1,
2296                    "max": 4
2297                  }
2298                }
2299                "#
2300                );
2301            },
2302        );
2303    }
2304
2305    #[test]
2306    fn go_for_loop() {
2307        check_metrics::<GoParser>(
2308            "package main
2309            func f() { // +2 (+1 unit)
2310                for i := 0; i < 10; i++ { // +1
2311                }
2312            }",
2313            "foo.go",
2314            |metric| {
2315                insta::assert_json_snapshot!(
2316                    metric.cyclomatic,
2317                    @r#"
2318                {
2319                  "sum": 3,
2320                  "value": 1,
2321                  "average": 3.0,
2322                  "min": 1,
2323                  "max": 2,
2324                  "modified": {
2325                    "sum": 3,
2326                    "value": 1,
2327                    "average": 3.0,
2328                    "min": 1,
2329                    "max": 2
2330                  }
2331                }
2332                "#
2333                );
2334            },
2335        );
2336    }
2337
2338    #[test]
2339    fn go_for_range() {
2340        check_metrics::<GoParser>(
2341            "package main
2342            func f(xs []int) { // +2 (+1 unit)
2343                for _, v := range xs { // +1
2344                    _ = v
2345                }
2346            }",
2347            "foo.go",
2348            |metric| {
2349                // range_clause is a child of for_statement; only the
2350                // for_statement contributes.
2351                insta::assert_json_snapshot!(
2352                    metric.cyclomatic,
2353                    @r#"
2354                {
2355                  "sum": 3,
2356                  "value": 1,
2357                  "average": 3.0,
2358                  "min": 1,
2359                  "max": 2,
2360                  "modified": {
2361                    "sum": 3,
2362                    "value": 1,
2363                    "average": 3.0,
2364                    "min": 1,
2365                    "max": 2
2366                  }
2367                }
2368                "#
2369                );
2370            },
2371        );
2372    }
2373
2374    #[test]
2375    fn go_switch() {
2376        check_metrics::<GoParser>(
2377            "package main
2378            func f(x int) { // +2 (+1 unit)
2379                switch x {
2380                case 1: // +1
2381                case 2: // +1
2382                default: // not counted
2383                }
2384            }",
2385            "foo.go",
2386            |metric| {
2387                insta::assert_json_snapshot!(
2388                    metric.cyclomatic,
2389                    @r#"
2390                {
2391                  "sum": 4,
2392                  "value": 1,
2393                  "average": 4.0,
2394                  "min": 1,
2395                  "max": 3,
2396                  "modified": {
2397                    "sum": 3,
2398                    "value": 1,
2399                    "average": 3.0,
2400                    "min": 1,
2401                    "max": 2
2402                  }
2403                }
2404                "#
2405                );
2406            },
2407        );
2408    }
2409
2410    /// Modified CCN: Go switch with 3 cases collapses to 1.
2411    #[test]
2412    fn go_switch_modified() {
2413        check_metrics::<GoParser>(
2414            "package main
2415            func f(x int) {
2416                switch x {
2417                case 1:
2418                    println(\"one\")
2419                case 2:
2420                    println(\"two\")
2421                case 3:
2422                    println(\"three\")
2423                }
2424            }",
2425            "foo.go",
2426            |metric| {
2427                // standard: unit(1) + fn(1) + 3 cases = 5
2428                // modified: unit(1) + fn(1) + switch(1) = 3
2429                insta::assert_json_snapshot!(
2430                    metric.cyclomatic,
2431                    @r#"
2432                {
2433                  "sum": 5,
2434                  "value": 1,
2435                  "average": 5.0,
2436                  "min": 1,
2437                  "max": 4,
2438                  "modified": {
2439                    "sum": 3,
2440                    "value": 1,
2441                    "average": 3.0,
2442                    "min": 1,
2443                    "max": 2
2444                  }
2445                }
2446                "#
2447                );
2448            },
2449        );
2450    }
2451
2452    #[test]
2453    fn go_type_switch() {
2454        check_metrics::<GoParser>(
2455            "package main
2456            func f(x interface{}) { // +2 (+1 unit)
2457                switch x.(type) {
2458                case int: // +1
2459                case string: // +1
2460                }
2461            }",
2462            "foo.go",
2463            |metric| {
2464                insta::assert_json_snapshot!(
2465                    metric.cyclomatic,
2466                    @r#"
2467                {
2468                  "sum": 4,
2469                  "value": 1,
2470                  "average": 4.0,
2471                  "min": 1,
2472                  "max": 3,
2473                  "modified": {
2474                    "sum": 3,
2475                    "value": 1,
2476                    "average": 3.0,
2477                    "min": 1,
2478                    "max": 2
2479                  }
2480                }
2481                "#
2482                );
2483            },
2484        );
2485    }
2486
2487    #[test]
2488    fn go_select() {
2489        check_metrics::<GoParser>(
2490            "package main
2491            func f(c1, c2 chan int) { // +2 (+1 unit)
2492                select {
2493                case <-c1: // +1
2494                case <-c2: // +1
2495                default: // not counted
2496                }
2497            }",
2498            "foo.go",
2499            |metric| {
2500                insta::assert_json_snapshot!(
2501                    metric.cyclomatic,
2502                    @r#"
2503                {
2504                  "sum": 4,
2505                  "value": 1,
2506                  "average": 4.0,
2507                  "min": 1,
2508                  "max": 3,
2509                  "modified": {
2510                    "sum": 3,
2511                    "value": 1,
2512                    "average": 3.0,
2513                    "min": 1,
2514                    "max": 2
2515                  }
2516                }
2517                "#
2518                );
2519            },
2520        );
2521    }
2522
2523    #[test]
2524    fn go_logical_operators() {
2525        check_metrics::<GoParser>(
2526            "package main
2527            func f(a, b, c bool) { // +2 (+1 unit)
2528                if a && b || c { // +1 if, +1 &&, +1 ||
2529                }
2530            }",
2531            "foo.go",
2532            |metric| {
2533                insta::assert_json_snapshot!(
2534                    metric.cyclomatic,
2535                    @r#"
2536                {
2537                  "sum": 5,
2538                  "value": 1,
2539                  "average": 5.0,
2540                  "min": 1,
2541                  "max": 4,
2542                  "modified": {
2543                    "sum": 5,
2544                    "value": 1,
2545                    "average": 5.0,
2546                    "min": 1,
2547                    "max": 4
2548                  }
2549                }
2550                "#
2551                );
2552            },
2553        );
2554    }
2555
2556    #[test]
2557    fn go_defer_and_go_do_not_count() {
2558        check_metrics::<GoParser>(
2559            "package main
2560            func f() { // +2 (+1 unit)
2561                defer cleanup()
2562                go work()
2563            }",
2564            "foo.go",
2565            |metric| {
2566                // defer_statement and go_statement are not branches.
2567                insta::assert_json_snapshot!(
2568                    metric.cyclomatic,
2569                    @r#"
2570                {
2571                  "sum": 2,
2572                  "value": 1,
2573                  "average": 2.0,
2574                  "min": 1,
2575                  "max": 1,
2576                  "modified": {
2577                    "sum": 2,
2578                    "value": 1,
2579                    "average": 2.0,
2580                    "min": 1,
2581                    "max": 1
2582                  }
2583                }
2584                "#
2585                );
2586            },
2587        );
2588    }
2589
2590    // As reported here:
2591    // https://github.com/sebastianbergmann/php-code-coverage/issues/607
2592    // An anonymous class declaration is not considered when computing the Cyclomatic Complexity metric for Java
2593    // Only the complexity of the anonymous class content is considered for the computation
2594    #[test]
2595    fn java_anonymous_class() {
2596        check_metrics::<JavaParser>(
2597            "
2598            abstract class A { // +2 (+1 unit space)
2599                public abstract boolean m1(int n); // +1
2600                public abstract boolean m2(int n); // +1
2601            }
2602            public class B { // +1
2603                public void test() { // +1
2604                    A a = new A() {
2605                        public boolean m1(int n) { // +1
2606                            if (n % 2 == 0) { // +1
2607                                return true;
2608                            }
2609                            return false;
2610                        }
2611                        public boolean m2(int n) { // +1
2612                            if (n % 5 == 0) { // +1
2613                                return true;
2614                            }
2615                            return false;
2616                        }
2617                    };
2618                }
2619            }",
2620            "foo.java",
2621            |metric| {
2622                // nspace = 9: unit, the two named classes (A, B), the
2623                // anonymous class (`new A() { ... }`, now its own Class
2624                // space — #463), and 5 methods. The anonymous class adds a
2625                // base +1 to the cyclomatic sum exactly like a named class,
2626                // so the file-level sum is 11 (was 10 before the anonymous
2627                // body opened its own space). Each method's complexity is
2628                // counted once — the +1 is the new class space, not a
2629                // re-count of any method.
2630                insta::assert_json_snapshot!(
2631                    metric.cyclomatic,
2632                    @r#"
2633                {
2634                  "sum": 11,
2635                  "value": 1,
2636                  "average": 2.2,
2637                  "min": 1,
2638                  "max": 2,
2639                  "modified": {
2640                    "sum": 11,
2641                    "value": 1,
2642                    "average": 2.2,
2643                    "min": 1,
2644                    "max": 2
2645                  }
2646                }
2647                "#
2648                );
2649            },
2650        );
2651    }
2652
2653    /// Java `do { … } while (…)` contributes exactly +1 to both
2654    /// standard and modified CCN. The +1 comes from the `while`
2655    /// keyword token (`Java::While`) inside the do-statement, which
2656    /// the dedicated `JavaCode` impl already counts. Adding
2657    /// `Java::DoStatement` would double-count — see issue #284. This
2658    /// test pins the correct keyword-driven count.
2659    #[test]
2660    fn java_do_statement_counts_in_cyclomatic() {
2661        check_metrics::<JavaParser>(
2662            "class Parity {
2663                 static void f() {
2664                     int i = 0;
2665                     do {           // +1 (via inner `while` keyword)
2666                         ++i;
2667                     } while (i < 10);
2668                 }
2669             }",
2670            "foo.java",
2671            |metric| {
2672                // standard: unit(1) + class(1) + method(1) + do(1) = 4
2673                let s = &metric.cyclomatic;
2674                assert_eq!(s.cyclomatic_sum(), 4);
2675                assert_eq!(s.cyclomatic_max(), 2);
2676                assert_eq!(s.cyclomatic_modified_sum(), 4);
2677                insta::assert_json_snapshot!(
2678                    metric.cyclomatic,
2679                    @r#"
2680                {
2681                  "sum": 4,
2682                  "value": 1,
2683                  "average": 4.0,
2684                  "min": 1,
2685                  "max": 2,
2686                  "modified": {
2687                    "sum": 4,
2688                    "value": 1,
2689                    "average": 4.0,
2690                    "min": 1,
2691                    "max": 2
2692                  }
2693                }
2694                "#
2695                );
2696            },
2697        );
2698    }
2699
2700    /// Java enhanced-for `for (T x : xs)` contributes exactly +1 to
2701    /// both standard and modified CCN — the `for` keyword token
2702    /// (`Java::For`) fires inside the `EnhancedForStatement` node
2703    /// just like inside a classic `ForStatement`. Pinning this
2704    /// prevents reintroducing the double-count from issue #284's
2705    /// incorrect fix proposal.
2706    #[test]
2707    fn java_enhanced_for_statement_counts_in_cyclomatic() {
2708        check_metrics::<JavaParser>(
2709            "class Parity {
2710                 static void f(int[] xs) {
2711                     for (int x : xs) {  // +1 (via `for` keyword)
2712                         g(x);
2713                     }
2714                 }
2715             }",
2716            "foo.java",
2717            |metric| {
2718                // standard: unit(1) + class(1) + method(1) + enhanced-for(1) = 4
2719                let s = &metric.cyclomatic;
2720                assert_eq!(s.cyclomatic_sum(), 4);
2721                assert_eq!(s.cyclomatic_max(), 2);
2722                assert_eq!(s.cyclomatic_modified_sum(), 4);
2723                insta::assert_json_snapshot!(
2724                    metric.cyclomatic,
2725                    @r#"
2726                {
2727                  "sum": 4,
2728                  "value": 1,
2729                  "average": 4.0,
2730                  "min": 1,
2731                  "max": 2,
2732                  "modified": {
2733                    "sum": 4,
2734                    "value": 1,
2735                    "average": 4.0,
2736                    "min": 1,
2737                    "max": 2
2738                  }
2739                }
2740                "#
2741                );
2742            },
2743        );
2744    }
2745
2746    #[test]
2747    fn groovy_simple_class() {
2748        check_metrics::<GroovyParser>(
2749            "
2750            class Example {
2751                int a = 10
2752                boolean b = (a > 5) ? true : false
2753                boolean c = b && true
2754
2755                void m1() {
2756                    if (a % 2 == 0) {
2757                        b = b || c
2758                    }
2759                }
2760                void m2() {
2761                    while (a > 3) {
2762                        m1()
2763                        a--
2764                    }
2765                }
2766            }",
2767            "foo.groovy",
2768            |metric| {
2769                // Same shape as `java_simple_class`. nspace = 4
2770                // (unit, class, 2 methods); branches mirror Java's.
2771                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 9);
2772            },
2773        );
2774    }
2775
2776    #[test]
2777    fn groovy_nested_control_flow() {
2778        check_metrics::<GroovyParser>(
2779            "void f(int x) {
2780                if (x > 0) {
2781                    while (x < 100) {
2782                        x = x + 1
2783                    }
2784                }
2785            }",
2786            "foo.groovy",
2787            |metric| {
2788                // unit(1) + fn(1) + if(1) + while(1) = 4
2789                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
2790            },
2791        );
2792    }
2793
2794    #[test]
2795    fn groovy_switch_with_cases() {
2796        check_metrics::<GroovyParser>(
2797            "void print(int result) {
2798                switch (result) {
2799                    case -1:
2800                        println 'minus one'
2801                        break
2802                    case -2:
2803                        println 'minus two'
2804                        break
2805                    default:
2806                        println 'other'
2807                }
2808            }",
2809            "foo.groovy",
2810            |metric| {
2811                // standard: unit(1) + fn(1) + 2 cases = 4
2812                // modified: unit(1) + fn(1) + switch(1) = 3
2813                // (default does NOT add a branch — same as Java/lesson #106)
2814                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
2815                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
2816            },
2817        );
2818    }
2819
2820    #[test]
2821    fn groovy_try_catch() {
2822        check_metrics::<GroovyParser>(
2823            "void f() {
2824                try {
2825                    risky()
2826                } catch (Exception e) {
2827                    handle(e)
2828                }
2829            }",
2830            "foo.groovy",
2831            |metric| {
2832                // unit(1) + fn(1) + catch(1) = 3
2833                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
2834            },
2835        );
2836    }
2837
2838    #[test]
2839    fn groovy_closure_body_short_circuit() {
2840        // Top-level `def pred = { … }` collapses the closure into the
2841        // unit scope (no class wrapper), so the `&&` inside still
2842        // contributes one branch but no extra function space is
2843        // created. Mirrors Java's top-level-lambda behavior.
2844        check_metrics::<GroovyParser>(
2845            "def pred = { x -> x > 0 && x < 100 }",
2846            "foo.groovy",
2847            |metric| {
2848                // unit(1) + && (1) = 2
2849                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 2);
2850            },
2851        );
2852    }
2853
2854    #[test]
2855    fn groovy_assert_adds_branch() {
2856        // Groovy `assert` is a runtime check that branches on its
2857        // condition; mirror Sonar's standard-CCN treatment.
2858        check_metrics::<GroovyParser>(
2859            "void check(int x) {
2860                assert x > 0
2861            }",
2862            "foo.groovy",
2863            |metric| {
2864                // unit(1) + fn(1) + assert(1) = 3
2865                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
2866            },
2867        );
2868    }
2869
2870    /// Groovy `do { … } while (…)` contributes exactly +1 to both
2871    /// standard and modified CCN — the `while` keyword token
2872    /// (`Groovy::While`) inside the do-statement is already counted
2873    /// by the dedicated `GroovyCode` impl. Adding `Groovy::DoStatement`
2874    /// would double-count (issue #284). This test pins the correct
2875    /// keyword-driven count.
2876    #[test]
2877    fn groovy_do_statement_counts_in_cyclomatic() {
2878        check_metrics::<GroovyParser>(
2879            "def f() {
2880                 int i = 0
2881                 do {           // +1 (via inner `while` keyword)
2882                     ++i
2883                 } while (i < 10)
2884             }",
2885            "foo.groovy",
2886            |metric| {
2887                // standard: unit(1) + fn(1) + do(1) = 3
2888                let s = &metric.cyclomatic;
2889                assert_eq!(s.cyclomatic_sum(), 3);
2890                assert_eq!(s.cyclomatic_max(), 2);
2891                assert_eq!(s.cyclomatic_modified_sum(), 3);
2892            },
2893        );
2894    }
2895
2896    /// Groovy enhanced-for `for (T x : xs)` contributes exactly +1 to
2897    /// both standard and modified CCN — the `for` keyword token
2898    /// (`Groovy::For`) fires inside `EnhancedForStatement` just like
2899    /// inside a classic `ForStatement`. Pinning this prevents
2900    /// reintroducing the double-count from issue #284's incorrect fix
2901    /// proposal.
2902    #[test]
2903    fn groovy_enhanced_for_statement_counts_in_cyclomatic() {
2904        check_metrics::<GroovyParser>(
2905            "def f(int[] xs) {
2906                 for (int x : xs) {  // +1 (via `for` keyword)
2907                     println(x)
2908                 }
2909             }",
2910            "foo.groovy",
2911            |metric| {
2912                // standard: unit(1) + fn(1) + enhanced-for(1) = 3
2913                let s = &metric.cyclomatic;
2914                assert_eq!(s.cyclomatic_sum(), 3);
2915                assert_eq!(s.cyclomatic_max(), 2);
2916                assert_eq!(s.cyclomatic_modified_sum(), 3);
2917            },
2918        );
2919    }
2920
2921    #[test]
2922    fn groovy_safe_navigation_cyclomatic() {
2923        // Issue #452: Groovy's safe-navigation `?.` (QMARKDOT) is a
2924        // short-circuit decision point per link, mirroring the
2925        // Kotlin/PHP/JS/C# treatment of `?.` (#281). The chain
2926        // `a?.b?.c` adds +2 to both standard and modified CCN.
2927        check_metrics::<GroovyParser>("def read(a){ return a?.b?.c }", "foo.groovy", |metric| {
2928            // unit(1) + fn(base 1 + ?. 1 + ?. 1) = sum 4, max 3.
2929            let s = &metric.cyclomatic;
2930            assert_eq!(s.cyclomatic_sum(), 4);
2931            assert_eq!(s.cyclomatic_max(), 3);
2932            assert_eq!(s.cyclomatic_modified_sum(), 4);
2933            assert_eq!(s.cyclomatic_modified_max(), 3);
2934        });
2935    }
2936
2937    #[test]
2938    fn groovy_safe_chain_dot_cyclomatic() {
2939        // Issue #452: Groovy's `??.` (QMARKQMARKDOT, the spread-safe
2940        // chain-dot operator) is also a short-circuit decision point,
2941        // counted once per occurrence like `?.`.
2942        check_metrics::<GroovyParser>("def read(a){ return a??.b }", "foo.groovy", |metric| {
2943            // unit(1) + fn(base 1 + ??. 1) = sum 3, max 2.
2944            let s = &metric.cyclomatic;
2945            assert_eq!(s.cyclomatic_sum(), 3);
2946            assert_eq!(s.cyclomatic_max(), 2);
2947            assert_eq!(s.cyclomatic_modified_sum(), 3);
2948            assert_eq!(s.cyclomatic_modified_max(), 2);
2949        });
2950    }
2951
2952    #[test]
2953    fn perl_nested_control_flow() {
2954        check_metrics::<PerlParser>(
2955            "sub f { # +1 (unit) +1 (sub)
2956                for my $i (1..10) { # +1 for_statement_2
2957                    if ($i % 2) { # +1 if_statement
2958                        print $i;
2959                    }
2960                }
2961            }",
2962            "foo.pl",
2963            |metric| {
2964                insta::assert_json_snapshot!(
2965                    metric.cyclomatic,
2966                    @r#"
2967                {
2968                  "sum": 4,
2969                  "value": 1,
2970                  "average": 4.0,
2971                  "min": 1,
2972                  "max": 3,
2973                  "modified": {
2974                    "sum": 4,
2975                    "value": 1,
2976                    "average": 4.0,
2977                    "min": 1,
2978                    "max": 3
2979                  }
2980                }
2981                "#
2982                );
2983            },
2984        );
2985    }
2986
2987    #[test]
2988    fn perl_postfix_conditionals() {
2989        check_metrics::<PerlParser>(
2990            "sub f { # +1 (unit) +1 (sub)
2991                return 1 if $_[0]; # +1 if_simple_statement
2992                return 0 unless $_[1]; # +1 unless_simple_statement
2993            }",
2994            "foo.pl",
2995            |metric| {
2996                insta::assert_json_snapshot!(
2997                    metric.cyclomatic,
2998                    @r#"
2999                {
3000                  "sum": 4,
3001                  "value": 1,
3002                  "average": 4.0,
3003                  "min": 1,
3004                  "max": 3,
3005                  "modified": {
3006                    "sum": 4,
3007                    "value": 1,
3008                    "average": 4.0,
3009                    "min": 1,
3010                    "max": 3
3011                  }
3012                }
3013                "#
3014                );
3015            },
3016        );
3017    }
3018
3019    #[test]
3020    fn perl_unless_and_until() {
3021        check_metrics::<PerlParser>(
3022            "sub f { # +1 (unit) +1 (sub)
3023                unless ($x) { # +1 unless_statement
3024                    print 'a';
3025                }
3026                until ($n == 0) { # +1 until_statement
3027                    $n--;
3028                }
3029            }",
3030            "foo.pl",
3031            |metric| {
3032                insta::assert_json_snapshot!(
3033                    metric.cyclomatic,
3034                    @r#"
3035                {
3036                  "sum": 4,
3037                  "value": 1,
3038                  "average": 4.0,
3039                  "min": 1,
3040                  "max": 3,
3041                  "modified": {
3042                    "sum": 4,
3043                    "value": 1,
3044                    "average": 4.0,
3045                    "min": 1,
3046                    "max": 3
3047                  }
3048                }
3049                "#
3050                );
3051            },
3052        );
3053    }
3054
3055    #[test]
3056    fn perl_logical_operators_and_ternary() {
3057        check_metrics::<PerlParser>(
3058            "sub f { # +1 (unit) +1 (sub)
3059                my $x = $a && $b; # +1 (&&)
3060                my $y = $c || $d; # +1 (||)
3061                my $z = $e // $f; # +1 (//)
3062                my $t = $g ? 1 : 0; # +1 ternary
3063            }",
3064            "foo.pl",
3065            |metric| {
3066                insta::assert_json_snapshot!(
3067                    metric.cyclomatic,
3068                    @r#"
3069                {
3070                  "sum": 6,
3071                  "value": 1,
3072                  "average": 6.0,
3073                  "min": 1,
3074                  "max": 5,
3075                  "modified": {
3076                    "sum": 6,
3077                    "value": 1,
3078                    "average": 6.0,
3079                    "min": 1,
3080                    "max": 5
3081                  }
3082                }
3083                "#
3084                );
3085            },
3086        );
3087    }
3088
3089    #[test]
3090    fn perl_word_logical_operators() {
3091        check_metrics::<PerlParser>(
3092            "sub f { # +1 (unit) +1 (sub)
3093                my $x = $a and $b; # +1 (and)
3094                my $y = $c or $d; # +1 (or)
3095            }",
3096            "foo.pl",
3097            |metric| {
3098                insta::assert_json_snapshot!(
3099                    metric.cyclomatic,
3100                    @r#"
3101                {
3102                  "sum": 4,
3103                  "value": 1,
3104                  "average": 4.0,
3105                  "min": 1,
3106                  "max": 3,
3107                  "modified": {
3108                    "sum": 4,
3109                    "value": 1,
3110                    "average": 4.0,
3111                    "min": 1,
3112                    "max": 3
3113                  }
3114                }
3115                "#
3116                );
3117            },
3118        );
3119    }
3120
3121    #[test]
3122    fn perl_compound_short_circuit_assignment_249() {
3123        // Regression for issue #249: `&&=`, `||=`, `//=` are each one
3124        // short-circuit decision edge — semantically `$x = $x op $y`.
3125        // Perl exposes the operator token inside `binary_expression`,
3126        // so adding the three `*EQ` tokens to the cyclomatic arm picks
3127        // them up alongside the bare `&&` / `||` / `//`.
3128        check_metrics::<PerlParser>(
3129            "sub f { # +1 (unit) +1 (sub)
3130                my ($x, $y, $z) = @_;
3131                $x ||= 1; # +1 (||=)
3132                $y &&= 2; # +1 (&&=)
3133                $z //= 3; # +1 (//=)
3134                return $x;
3135            }",
3136            "foo.pl",
3137            |metric| {
3138                // unit(1) + fn(entry 1 + 3 assignments = 4) = sum 5, max 4.
3139                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
3140                assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
3141                insta::assert_json_snapshot!(
3142                    metric.cyclomatic,
3143                    @r#"
3144                {
3145                  "sum": 5,
3146                  "value": 1,
3147                  "average": 5.0,
3148                  "min": 1,
3149                  "max": 4,
3150                  "modified": {
3151                    "sum": 5,
3152                    "value": 1,
3153                    "average": 5.0,
3154                    "min": 1,
3155                    "max": 4
3156                  }
3157                }
3158                "#
3159                );
3160            },
3161        );
3162    }
3163
3164    #[test]
3165    fn perl_foreach_loop() {
3166        check_metrics::<PerlParser>(
3167            "sub f { # +1 (unit) +1 (sub)
3168                foreach my $i (@list) { # +1 for_statement_2
3169                    print $i;
3170                }
3171            }",
3172            "foo.pl",
3173            |metric| {
3174                insta::assert_json_snapshot!(metric.cyclomatic, @r#"
3175                {
3176                  "sum": 3,
3177                  "value": 1,
3178                  "average": 3.0,
3179                  "min": 1,
3180                  "max": 2,
3181                  "modified": {
3182                    "sum": 3,
3183                    "value": 1,
3184                    "average": 3.0,
3185                    "min": 1,
3186                    "max": 2
3187                  }
3188                }
3189                "#);
3190            },
3191        );
3192    }
3193
3194    #[test]
3195    fn perl_else_does_not_count_but_elsif_does() {
3196        check_metrics::<PerlParser>(
3197            "sub f { # +1 (unit) +1 (sub)
3198                if ($x) { # +1 if_statement
3199                    print 'a';
3200                } elsif ($y) { # +1 elsif_clause
3201                    print 'b';
3202                } else {
3203                    print 'c';
3204                }
3205            }",
3206            "foo.pl",
3207            |metric| {
3208                insta::assert_json_snapshot!(
3209                    metric.cyclomatic,
3210                    @r#"
3211                {
3212                  "sum": 4,
3213                  "value": 1,
3214                  "average": 4.0,
3215                  "min": 1,
3216                  "max": 3,
3217                  "modified": {
3218                    "sum": 4,
3219                    "value": 1,
3220                    "average": 4.0,
3221                    "min": 1,
3222                    "max": 3
3223                  }
3224                }
3225                "#
3226                );
3227            },
3228        );
3229    }
3230
3231    #[test]
3232    fn tsx_simple_function() {
3233        check_metrics::<TsxParser>(
3234            "function f(a: number, b: number) { // +2 (+1 unit space)
3235                 if (a > 0) { // +1
3236                     return a;
3237                 } else if (b > 0) { // +1
3238                     return b;
3239                 }
3240                 return 0;
3241             }",
3242            "foo.tsx",
3243            |metric| {
3244                insta::assert_json_snapshot!(
3245                    metric.cyclomatic,
3246                    @r#"
3247                {
3248                  "sum": 4,
3249                  "value": 1,
3250                  "average": 4.0,
3251                  "min": 1,
3252                  "max": 3,
3253                  "modified": {
3254                    "sum": 4,
3255                    "value": 1,
3256                    "average": 4.0,
3257                    "min": 1,
3258                    "max": 3
3259                  }
3260                }
3261                "#
3262                );
3263            },
3264        );
3265    }
3266
3267    #[test]
3268    fn typescript_if_else_and_switch() {
3269        check_metrics::<TypescriptParser>(
3270            "function classify(value: number): string {
3271                 if (value < 0) { // +1
3272                     return 'negative';
3273                 } else if (value === 0) { // +1
3274                     return 'zero';
3275                 }
3276                 switch (value) {
3277                     case 1: // +1
3278                         return 'one';
3279                     case 2: // +1
3280                         return 'two';
3281                     default:
3282                         return 'other';
3283                 }
3284             }",
3285            "foo.ts",
3286            |metric| {
3287                insta::assert_json_snapshot!(
3288                    metric.cyclomatic,
3289                    @r#"
3290                {
3291                  "sum": 6,
3292                  "value": 1,
3293                  "average": 6.0,
3294                  "min": 1,
3295                  "max": 5,
3296                  "modified": {
3297                    "sum": 5,
3298                    "value": 1,
3299                    "average": 5.0,
3300                    "min": 1,
3301                    "max": 4
3302                  }
3303                }
3304                "#
3305                );
3306            },
3307        );
3308    }
3309
3310    /// Modified CCN: TypeScript switch with 3 cases collapses to 1.
3311    #[test]
3312    fn typescript_switch_modified() {
3313        check_metrics::<TypescriptParser>(
3314            "function f(x: number): string {
3315                 switch (x) {
3316                     case 1: return 'one';
3317                     case 2: return 'two';
3318                     case 3: return 'three';
3319                     default: return 'other';
3320                 }
3321             }",
3322            "foo.ts",
3323            |metric| {
3324                // standard: unit(1) + fn(1) + 3 cases = 5
3325                // modified: unit(1) + fn(1) + switch(1) = 3
3326                insta::assert_json_snapshot!(
3327                    metric.cyclomatic,
3328                    @r#"
3329                {
3330                  "sum": 5,
3331                  "value": 1,
3332                  "average": 5.0,
3333                  "min": 1,
3334                  "max": 4,
3335                  "modified": {
3336                    "sum": 3,
3337                    "value": 1,
3338                    "average": 3.0,
3339                    "min": 1,
3340                    "max": 2
3341                  }
3342                }
3343                "#
3344                );
3345            },
3346        );
3347    }
3348
3349    #[test]
3350    fn mozjs_if_else_and_switch() {
3351        check_metrics::<MozjsParser>(
3352            "function f(x) { // +2 (+1 unit space)
3353                 if (x > 0) { // +1
3354                     return 1;
3355                 } else if (x < 0) { // +1
3356                     return -1;
3357                 }
3358                 switch (x) {
3359                     case 0: // +1
3360                         return 0;
3361                     case 42: // +1
3362                         return 42;
3363                     default:
3364                         return -2;
3365                 }
3366             }",
3367            "foo.js",
3368            |metric| {
3369                insta::assert_json_snapshot!(
3370                    metric.cyclomatic,
3371                    @r#"
3372                {
3373                  "sum": 6,
3374                  "value": 1,
3375                  "average": 6.0,
3376                  "min": 1,
3377                  "max": 5,
3378                  "modified": {
3379                    "sum": 5,
3380                    "value": 1,
3381                    "average": 5.0,
3382                    "min": 1,
3383                    "max": 4
3384                  }
3385                }
3386                "#
3387                );
3388            },
3389        );
3390    }
3391
3392    /// Modified CCN: MozJS switch with 2 cases collapses to 1.
3393    #[test]
3394    fn mozjs_switch_modified() {
3395        check_metrics::<MozjsParser>(
3396            "function f(x) {
3397                 switch (x) {
3398                     case 1: return 1;
3399                     case 2: return 2;
3400                 }
3401             }",
3402            "foo.js",
3403            |metric| {
3404                // standard: unit(1) + fn(1) + 2 cases = 4
3405                // modified: unit(1) + fn(1) + switch(1) = 3
3406                insta::assert_json_snapshot!(
3407                    metric.cyclomatic,
3408                    @r#"
3409                {
3410                  "sum": 4,
3411                  "value": 1,
3412                  "average": 4.0,
3413                  "min": 1,
3414                  "max": 3,
3415                  "modified": {
3416                    "sum": 3,
3417                    "value": 1,
3418                    "average": 3.0,
3419                    "min": 1,
3420                    "max": 2
3421                  }
3422                }
3423                "#
3424                );
3425            },
3426        );
3427    }
3428
3429    #[test]
3430    fn kotlin_cyclomatic_mixed() {
3431        check_metrics::<KotlinParser>(
3432            "class Calc {
3433                fun compute(x: Int, y: Int): Int {
3434                    if (x > 0) {            // +1
3435                        for (i in 1..x) {   // +1
3436                            println(i)
3437                        }
3438                    }
3439                    when (y) {
3440                        1 -> println(\"one\")  // +1 (WhenEntry)
3441                        2 -> println(\"two\")  // +1
3442                        else -> println(\"?\") // skipped (else is default)
3443                    }
3444                    val ok = x > 0 && y > 0  // +1
3445                    try {
3446                        println(x / y)
3447                    } catch (e: Exception) { // +1
3448                        println(\"err\")
3449                    }
3450                    return x + y
3451                }
3452            }",
3453            "foo.kt",
3454            |metric| {
3455                // expected: unit(1) + class(1) + fn(base 1 + if 1 + for 1 +
3456                //           2 explicit when arms; else skipped + && 1 +
3457                //           catch 1) = sum 9, max 7.
3458                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 9);
3459                assert_eq!(metric.cyclomatic.cyclomatic_max(), 7);
3460                insta::assert_json_snapshot!(
3461                    metric.cyclomatic,
3462                    @r#"
3463                {
3464                  "sum": 9,
3465                  "value": 1,
3466                  "average": 9.0,
3467                  "min": 1,
3468                  "max": 7,
3469                  "modified": {
3470                    "sum": 8,
3471                    "value": 1,
3472                    "average": 8.0,
3473                    "min": 1,
3474                    "max": 6
3475                  }
3476                }
3477                "#
3478                );
3479            },
3480        );
3481    }
3482
3483    /// Modified CCN: Kotlin when with 3 entries collapses to 1.
3484    #[test]
3485    fn kotlin_when_modified() {
3486        check_metrics::<KotlinParser>(
3487            "fun describe(x: Int): String {
3488                 return when (x) {
3489                     1 -> \"one\"
3490                     2 -> \"two\"
3491                     3 -> \"three\"
3492                     else -> \"other\"
3493                 }
3494             }",
3495            "foo.kt",
3496            |metric| {
3497                // standard: unit(1) + fn(base 1 + 3 explicit when arms;
3498                //           else skipped per #282) = 5
3499                // modified: unit(1) + fn(1) + WhenExpression(1) = 3
3500                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
3501                assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
3502                insta::assert_json_snapshot!(
3503                    metric.cyclomatic,
3504                    @r#"
3505                {
3506                  "sum": 5,
3507                  "value": 1,
3508                  "average": 5.0,
3509                  "min": 1,
3510                  "max": 4,
3511                  "modified": {
3512                    "sum": 3,
3513                    "value": 1,
3514                    "average": 3.0,
3515                    "min": 1,
3516                    "max": 2
3517                  }
3518                }
3519                "#
3520                );
3521            },
3522        );
3523    }
3524
3525    /// Regression #282: the `else -> …` arm in a Kotlin `when`
3526    /// expression must NOT contribute to standard CCN, mirroring the
3527    /// C-family `default:` rule.
3528    #[test]
3529    fn kotlin_when_else_arm_not_counted() {
3530        check_metrics::<KotlinParser>(
3531            "fun describe(x: Int): String {
3532                 return when (x) {
3533                     1 -> \"one\"
3534                     else -> \"other\"
3535                 }
3536             }",
3537            "foo.kt",
3538            |metric| {
3539                // expected: unit(1) + fn(base 1 + 1 explicit; else skipped) = 3, max 2.
3540                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
3541                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
3542            },
3543        );
3544    }
3545
3546    /// Cross-check #282: every case-style arm in a Kotlin `when`
3547    /// contributes one standard decision; only the `else ->` arm is
3548    /// skipped. Pairs with `kotlin_when_else_arm_not_counted` (which
3549    /// pins the single-explicit case) to confirm the count scales
3550    /// linearly with explicit arms and is not accidentally hard-coded
3551    /// to one.
3552    #[test]
3553    fn kotlin_when_multiple_explicit_arms_each_count() {
3554        check_metrics::<KotlinParser>(
3555            "fun describe(x: Int): String {
3556                 return when (x) {
3557                     1 -> \"one\"
3558                     2 -> \"two\"
3559                     3 -> \"three\"
3560                     else -> \"other\"
3561                 }
3562             }",
3563            "foo.kt",
3564            |metric| {
3565                // expected: unit(1) + fn(base 1 + 3 explicit; else skipped) = 5, max 4.
3566                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
3567                assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
3568            },
3569        );
3570    }
3571
3572    #[test]
3573    fn lua_1_level_nesting() {
3574        // chunk: base=1; f: base=1 + for=1 + if=1 = 3; sum=4
3575        check_metrics::<LuaParser>(
3576            "local function f(t)
3577  for i = 1, #t do
3578    if t[i] > 0 then
3579      return t[i]
3580    end
3581  end
3582  return 0
3583end",
3584            "foo.lua",
3585            |metric| {
3586                insta::assert_json_snapshot!(metric.cyclomatic, @r#"
3587                {
3588                  "sum": 4,
3589                  "value": 1,
3590                  "average": 4.0,
3591                  "min": 1,
3592                  "max": 3,
3593                  "modified": {
3594                    "sum": 4,
3595                    "value": 1,
3596                    "average": 4.0,
3597                    "min": 1,
3598                    "max": 3
3599                  }
3600                }
3601                "#);
3602            },
3603        );
3604    }
3605
3606    #[test]
3607    fn lua_elseif_branches() {
3608        // chunk: base=1; classify: base=1 + if=1 + elseif=1 + elseif=1 = 4
3609        // else does NOT add a branch; sum=5
3610        check_metrics::<LuaParser>(
3611            "local function classify(x)
3612  if x > 0 then
3613    return 1
3614  elseif x < 0 then
3615    return -1
3616  elseif x == 0 then
3617    return 0
3618  else
3619    return 0
3620  end
3621end",
3622            "foo.lua",
3623            |metric| {
3624                insta::assert_json_snapshot!(metric.cyclomatic, @r#"
3625                {
3626                  "sum": 5,
3627                  "value": 1,
3628                  "average": 5.0,
3629                  "min": 1,
3630                  "max": 4,
3631                  "modified": {
3632                    "sum": 5,
3633                    "value": 1,
3634                    "average": 5.0,
3635                    "min": 1,
3636                    "max": 4
3637                  }
3638                }
3639                "#);
3640            },
3641        );
3642    }
3643
3644    #[test]
3645    fn lua_logical_operators() {
3646        // chunk: base=1; f: base=1 + if=1 + and=1 + or=1 = 4; sum=5
3647        check_metrics::<LuaParser>(
3648            "local function f(a, b, c)
3649  if a and b or c then
3650    return 1
3651  end
3652  return 0
3653end",
3654            "foo.lua",
3655            |metric| {
3656                insta::assert_json_snapshot!(metric.cyclomatic, @r#"
3657                {
3658                  "sum": 5,
3659                  "value": 1,
3660                  "average": 5.0,
3661                  "min": 1,
3662                  "max": 4,
3663                  "modified": {
3664                    "sum": 5,
3665                    "value": 1,
3666                    "average": 5.0,
3667                    "min": 1,
3668                    "max": 4
3669                  }
3670                }
3671                "#);
3672            },
3673        );
3674    }
3675
3676    #[test]
3677    fn bash_nested_control_flow() {
3678        check_metrics::<BashParser>(
3679            "#!/bin/bash
3680f() {
3681    if [ $1 -eq 1 ]; then
3682        for i in 1 2 3; do
3683            echo $i
3684        done
3685    elif [ $1 -eq 2 ]; then
3686        echo 'two'
3687    fi
3688}",
3689            "foo.sh",
3690            |metric| {
3691                insta::assert_json_snapshot!(
3692                    metric.cyclomatic,
3693                    {".sum" => insta::rounded_redaction(2)}
3694                );
3695            },
3696        );
3697    }
3698
3699    /// Regression test for #107: case…esac must not double-count the container.
3700    /// Standard CCN counts only arms (matching C-family `switch` semantics).
3701    /// Modified CCN counts only the container.
3702    #[test]
3703    fn bash_case_modified() {
3704        check_metrics::<BashParser>(
3705            "#!/bin/bash
3706f() {
3707    case $1 in
3708        one)   echo 1 ;;
3709        two)   echo 2 ;;
3710        three) echo 3 ;;
3711    esac
3712}",
3713            "foo.sh",
3714            |metric| {
3715                // standard: unit(1) + fn(1) + 3 case_items = 5
3716                // modified: unit(1) + fn(1) + case_stmt(1) = 3
3717                insta::assert_json_snapshot!(
3718                    metric.cyclomatic,
3719                    @r#"
3720                {
3721                  "sum": 5,
3722                  "value": 1,
3723                  "average": 5.0,
3724                  "min": 1,
3725                  "max": 4,
3726                  "modified": {
3727                    "sum": 3,
3728                    "value": 1,
3729                    "average": 3.0,
3730                    "min": 1,
3731                    "max": 2
3732                  }
3733                }
3734                "#
3735                );
3736            },
3737        );
3738    }
3739
3740    #[test]
3741    fn tcl_1_level_nesting() {
3742        // chunk: base=1; f: base=1 + while=1 + if=1 = 3; sum=4
3743        check_metrics::<TclParser>(
3744            "proc f {x} {
3745    while {$x > 0} {
3746        if {$x > 10} {
3747            set x [expr {$x - 1}]
3748        }
3749    }
3750}",
3751            "foo.tcl",
3752            |metric| {
3753                // unit(1) + proc(base 1 + while 1 + if 1) = sum 4, max 3.
3754                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
3755                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
3756                insta::assert_json_snapshot!(metric.cyclomatic);
3757            },
3758        );
3759    }
3760
3761    #[test]
3762    fn tcl_elseif_branch() {
3763        // if=1, elseif=1; else does NOT add a branch; sum=3 (chunk base=1)
3764        check_metrics::<TclParser>(
3765            "proc f {x} {
3766    if {$x > 10} {
3767        puts big
3768    } elseif {$x > 5} {
3769        puts medium
3770    } else {
3771        puts small
3772    }
3773}",
3774            "foo.tcl",
3775            |metric| {
3776                // unit(1) + proc(base 1 + if 1 + elseif 1) = sum 4, max 3.
3777                // else does NOT add a branch.
3778                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
3779                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
3780                insta::assert_json_snapshot!(metric.cyclomatic);
3781            },
3782        );
3783    }
3784
3785    #[test]
3786    fn tcl_logical_operators() {
3787        check_metrics::<TclParser>(
3788            "proc f {x y z} {
3789    if {$x > 0 && $y > 0 || $z > 0} {
3790        puts ok
3791    }
3792}",
3793            "foo.tcl",
3794            |metric| {
3795                // unit(1) + proc(base 1 + if 1 + && 1 + || 1) = sum 5, max 4.
3796                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
3797                assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
3798                insta::assert_json_snapshot!(metric.cyclomatic);
3799            },
3800        );
3801    }
3802
3803    #[test]
3804    fn tcl_catch_branch() {
3805        // `catch` command adds +1 (conditional handler); `try` does NOT add a branch.
3806        // source_file(1) + proc_space(base=1 + catch=1 = 2) = sum=3
3807        check_metrics::<TclParser>(
3808            "proc f {} {
3809    catch {
3810        expr {1 / 0}
3811    } msg
3812}",
3813            "foo.tcl",
3814            |metric| {
3815                // unit(1) + proc(base 1 + catch 1) = sum 3, max 2.
3816                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
3817                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
3818                insta::assert_json_snapshot!(metric.cyclomatic);
3819            },
3820        );
3821    }
3822
3823    #[test]
3824    fn tcl_try_no_branch() {
3825        // `try` is NOT a conditional construct; it does not add cyclomatic complexity.
3826        // Only the base counts: source_file(1) + proc_space(base=1) = sum=2, average=1.
3827        check_metrics::<TclParser>(
3828            "proc f {} {
3829    try {
3830        expr {1 / 0}
3831    } finally {
3832        puts done
3833    }
3834}",
3835            "foo.tcl",
3836            |metric| {
3837                insta::assert_json_snapshot!(
3838                    metric.cyclomatic,
3839                    @r#"
3840                {
3841                  "sum": 2,
3842                  "value": 1,
3843                  "average": 2.0,
3844                  "min": 1,
3845                  "max": 1,
3846                  "modified": {
3847                    "sum": 2,
3848                    "value": 1,
3849                    "average": 2.0,
3850                    "min": 1,
3851                    "max": 1
3852                  }
3853                }
3854                "#
3855                );
3856            },
3857        );
3858    }
3859
3860    #[test]
3861    fn tcl_switch_cyclomatic() {
3862        // Tcl `switch` is a generic command; each non-`default` arm is a
3863        // decision point in standard CCN, while modified CCN counts the
3864        // construct once (issue #467). Three arms (1, 2, default): the two
3865        // non-default arms add +2 standard; `default` is free.
3866        check_metrics::<TclParser>(
3867            "proc f {x} {
3868    switch $x {
3869        1 { puts a }
3870        2 { puts b }
3871        default { puts c }
3872    }
3873}",
3874            "foo.tcl",
3875            |metric| {
3876                // unit(1) + proc(base 1 + arm 1 + arm 2) = standard sum 4, max 3.
3877                // modified collapses arms to one container: unit(1) +
3878                // proc(base 1 + switch 1) = sum 3, max 2.
3879                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
3880                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
3881                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
3882                assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 2);
3883            },
3884        );
3885    }
3886
3887    #[test]
3888    fn tcl_switch_cyclomatic_no_default_with_options() {
3889        // No `default` arm, and leading `switch` options (`-exact --`) precede
3890        // the value: the arm list is still the trailing braced word, so both
3891        // arms count. Guards the option-form arm-list location (issue #467).
3892        check_metrics::<TclParser>(
3893            "proc f {x} {
3894    switch -exact -- $x {
3895        1 { puts a }
3896        2 { puts b }
3897    }
3898}",
3899            "foo.tcl",
3900            |metric| {
3901                // unit(1) + proc(base 1 + arm 1 + arm 2) = standard sum 4, max 3.
3902                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
3903                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
3904                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
3905                assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 2);
3906            },
3907        );
3908    }
3909
3910    #[test]
3911    fn mozjs_for_loop() {
3912        check_metrics::<MozjsParser>(
3913            "function f(n) { // +2 (+1 unit)
3914             var s = 0;
3915             for (var i = 0; i < n; i++) { // +1
3916                 s += i;
3917             }
3918             return s;
3919         }",
3920            "foo.js",
3921            |metric| {
3922                // unit(1) + fn(base 1 + for 1) = sum 3, max 2.
3923                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
3924                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
3925                insta::assert_json_snapshot!(metric.cyclomatic);
3926            },
3927        );
3928    }
3929
3930    #[test]
3931    fn mozjs_logical_operators() {
3932        check_metrics::<MozjsParser>(
3933            "function f(a, b, c) { // +2 (+1 unit)
3934             if (a && b || c) { // +1 if, +1 &&, +1 ||
3935                 return 1;
3936             }
3937             return 0;
3938         }",
3939            "foo.js",
3940            |metric| {
3941                // unit(1) + fn(base 1 + if 1 + && 1 + || 1) = sum 5, max 4.
3942                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
3943                assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
3944                insta::assert_json_snapshot!(metric.cyclomatic);
3945            },
3946        );
3947    }
3948
3949    #[test]
3950    fn javascript_nullish_coalescing_chain_226() {
3951        // `??` is short-circuit and must count as
3952        // a decision point in cyclomatic complexity.  `a ?? b ?? c` adds two
3953        // `??` decisions on top of the function entry.
3954        check_metrics::<JavascriptParser>(
3955            "function pick(a, b, c) { // +1 (entry)
3956                 return a ?? b ?? c; // +2 (two `??`)
3957             }",
3958            "foo.js",
3959            |metric| {
3960                // unit(1) + fn(entry 1 + 2*?? = 3) = sum 4, max 3.
3961                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
3962                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
3963                insta::assert_json_snapshot!(
3964                    metric.cyclomatic,
3965                    @r#"
3966                {
3967                  "sum": 4,
3968                  "value": 1,
3969                  "average": 4.0,
3970                  "min": 1,
3971                  "max": 3,
3972                  "modified": {
3973                    "sum": 4,
3974                    "value": 1,
3975                    "average": 4.0,
3976                    "min": 1,
3977                    "max": 3
3978                  }
3979                }
3980                "#
3981                );
3982            },
3983        );
3984    }
3985
3986    #[test]
3987    fn typescript_nullish_coalescing_with_if_226() {
3988        // TypeScript must count `??` as a
3989        // decision.  This mirrors the example in the issue body.
3990        check_metrics::<TypescriptParser>(
3991            "function classify(x: string | null, fallback: string | null): string { // +1 (entry)
3992                 if (x === \"y\") return \"yes\"; // +1 (if)
3993                 return x ?? fallback ?? \"unknown\"; // +2 (two `??`)
3994             }",
3995            "foo.ts",
3996            |metric| {
3997                // unit(1) + fn(entry 1 + if 1 + 2*?? = 4) = sum 5, max 4.
3998                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
3999                assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
4000                insta::assert_json_snapshot!(
4001                    metric.cyclomatic,
4002                    @r#"
4003                {
4004                  "sum": 5,
4005                  "value": 1,
4006                  "average": 5.0,
4007                  "min": 1,
4008                  "max": 4,
4009                  "modified": {
4010                    "sum": 5,
4011                    "value": 1,
4012                    "average": 5.0,
4013                    "min": 1,
4014                    "max": 4
4015                  }
4016                }
4017                "#
4018                );
4019            },
4020        );
4021    }
4022
4023    #[test]
4024    fn tsx_nullish_coalescing_chain_226() {
4025        // TSX must count `??` the same as JS/TS.
4026        check_metrics::<TsxParser>(
4027            "function pick(a: number | null, b: number | null, c: number): number { // +1 (entry)
4028                 return a ?? b ?? c; // +2 (two `??`)
4029             }",
4030            "foo.tsx",
4031            |metric| {
4032                // unit(1) + fn(entry 1 + 2*?? = 3) = sum 4, max 3.
4033                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4034                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4035                insta::assert_json_snapshot!(
4036                    metric.cyclomatic,
4037                    @r#"
4038                {
4039                  "sum": 4,
4040                  "value": 1,
4041                  "average": 4.0,
4042                  "min": 1,
4043                  "max": 3,
4044                  "modified": {
4045                    "sum": 4,
4046                    "value": 1,
4047                    "average": 4.0,
4048                    "min": 1,
4049                    "max": 3
4050                  }
4051                }
4052                "#
4053                );
4054            },
4055        );
4056    }
4057
4058    #[test]
4059    fn mozjs_nullish_coalescing_chain_226() {
4060        // Mozjs must count `??` the same as JS.
4061        check_metrics::<MozjsParser>(
4062            "function pick(a, b, c) { // +1 (entry)
4063                 return a ?? b ?? c; // +2 (two `??`)
4064             }",
4065            "foo.js",
4066            |metric| {
4067                // unit(1) + fn(entry 1 + 2*?? = 3) = sum 4, max 3.
4068                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4069                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4070                insta::assert_json_snapshot!(
4071                    metric.cyclomatic,
4072                    @r#"
4073                {
4074                  "sum": 4,
4075                  "value": 1,
4076                  "average": 4.0,
4077                  "min": 1,
4078                  "max": 3,
4079                  "modified": {
4080                    "sum": 4,
4081                    "value": 1,
4082                    "average": 4.0,
4083                    "min": 1,
4084                    "max": 3
4085                  }
4086                }
4087                "#
4088                );
4089            },
4090        );
4091    }
4092
4093    #[test]
4094    fn javascript_nullish_coalescing_assignment_231() {
4095        // `x ??= y` is `x = x ?? y` — one short-circuit decision edge,
4096        // same as `??`. Two `??=` assignments add +2 on top of the entry.
4097        check_metrics::<JavascriptParser>(
4098            "function pick(o) { // +1 (entry)
4099                 o.x ??= 1; // +1 (??=)
4100                 o.y ??= 2; // +1 (??=)
4101                 return o;
4102             }",
4103            "foo.js",
4104            |metric| {
4105                // unit(1) + fn(entry 1 + 2*??= = 3) = sum 4, max 3.
4106                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4107                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4108                insta::assert_json_snapshot!(
4109                    metric.cyclomatic,
4110                    @r#"
4111                {
4112                  "sum": 4,
4113                  "value": 1,
4114                  "average": 4.0,
4115                  "min": 1,
4116                  "max": 3,
4117                  "modified": {
4118                    "sum": 4,
4119                    "value": 1,
4120                    "average": 4.0,
4121                    "min": 1,
4122                    "max": 3
4123                  }
4124                }
4125                "#
4126                );
4127            },
4128        );
4129    }
4130
4131    #[test]
4132    fn typescript_nullish_coalescing_assignment_231() {
4133        // TypeScript must count `??=` the same as JS.
4134        check_metrics::<TypescriptParser>(
4135            "function pick(o: { x?: number; y?: number }) { // +1 (entry)
4136                 o.x ??= 1; // +1 (??=)
4137                 o.y ??= 2; // +1 (??=)
4138                 return o;
4139             }",
4140            "foo.ts",
4141            |metric| {
4142                // unit(1) + fn(entry 1 + 2*??= = 3) = sum 4, max 3.
4143                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4144                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4145                insta::assert_json_snapshot!(
4146                    metric.cyclomatic,
4147                    @r#"
4148                {
4149                  "sum": 4,
4150                  "value": 1,
4151                  "average": 4.0,
4152                  "min": 1,
4153                  "max": 3,
4154                  "modified": {
4155                    "sum": 4,
4156                    "value": 1,
4157                    "average": 4.0,
4158                    "min": 1,
4159                    "max": 3
4160                  }
4161                }
4162                "#
4163                );
4164            },
4165        );
4166    }
4167
4168    #[test]
4169    fn tsx_nullish_coalescing_assignment_231() {
4170        // TSX must count `??=` the same as JS/TS.
4171        check_metrics::<TsxParser>(
4172            "function pick(o: { x?: number; y?: number }) { // +1 (entry)
4173                 o.x ??= 1; // +1 (??=)
4174                 o.y ??= 2; // +1 (??=)
4175                 return o;
4176             }",
4177            "foo.tsx",
4178            |metric| {
4179                // unit(1) + fn(entry 1 + 2*??= = 3) = sum 4, max 3.
4180                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4181                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4182                insta::assert_json_snapshot!(
4183                    metric.cyclomatic,
4184                    @r#"
4185                {
4186                  "sum": 4,
4187                  "value": 1,
4188                  "average": 4.0,
4189                  "min": 1,
4190                  "max": 3,
4191                  "modified": {
4192                    "sum": 4,
4193                    "value": 1,
4194                    "average": 4.0,
4195                    "min": 1,
4196                    "max": 3
4197                  }
4198                }
4199                "#
4200                );
4201            },
4202        );
4203    }
4204
4205    #[test]
4206    fn mozjs_nullish_coalescing_assignment_231() {
4207        // Mozjs must count `??=` the same as JS.
4208        check_metrics::<MozjsParser>(
4209            "function pick(o) { // +1 (entry)
4210                 o.x ??= 1; // +1 (??=)
4211                 o.y ??= 2; // +1 (??=)
4212                 return o;
4213             }",
4214            "foo.js",
4215            |metric| {
4216                // unit(1) + fn(entry 1 + 2*??= = 3) = sum 4, max 3.
4217                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4218                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4219                insta::assert_json_snapshot!(
4220                    metric.cyclomatic,
4221                    @r#"
4222                {
4223                  "sum": 4,
4224                  "value": 1,
4225                  "average": 4.0,
4226                  "min": 1,
4227                  "max": 3,
4228                  "modified": {
4229                    "sum": 4,
4230                    "value": 1,
4231                    "average": 4.0,
4232                    "min": 1,
4233                    "max": 3
4234                  }
4235                }
4236                "#
4237                );
4238            },
4239        );
4240    }
4241
4242    #[test]
4243    fn javascript_short_circuit_assignments_248() {
4244        // `&&=`, `||=`, `??=` are each one short-circuit decision edge —
4245        // semantically `x = x op y`. #231 added only `??=`; #248 adds the
4246        // sibling `&&=` and `||=`.
4247        check_metrics::<JavascriptParser>(
4248            "function f(x, y, z) { // +1 (entry)
4249                 x ??= 1; // +1 (??=)
4250                 y &&= 2; // +1 (&&=)
4251                 z ||= 3; // +1 (||=)
4252                 return x;
4253             }",
4254            "foo.js",
4255            |metric| {
4256                // unit(1) + fn(entry 1 + 3 assignments = 4) = sum 5, max 4.
4257                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
4258                assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
4259                insta::assert_json_snapshot!(
4260                    metric.cyclomatic,
4261                    @r#"
4262                {
4263                  "sum": 5,
4264                  "value": 1,
4265                  "average": 5.0,
4266                  "min": 1,
4267                  "max": 4,
4268                  "modified": {
4269                    "sum": 5,
4270                    "value": 1,
4271                    "average": 5.0,
4272                    "min": 1,
4273                    "max": 4
4274                  }
4275                }
4276                "#
4277                );
4278            },
4279        );
4280    }
4281
4282    #[test]
4283    fn typescript_short_circuit_assignments_248() {
4284        // TypeScript parallel of #248: `&&=` / `||=` / `??=` each +1.
4285        check_metrics::<TypescriptParser>(
4286            "function f(x: number | null, y: number | null, z: number | null): number { // +1 (entry)
4287                 x ??= 1; // +1 (??=)
4288                 y &&= 2; // +1 (&&=)
4289                 z ||= 3; // +1 (||=)
4290                 return x ?? 0; // +1 (??)
4291             }",
4292            "foo.ts",
4293            |metric| {
4294                // unit(1) + fn(entry 1 + 3 op= + 1 `??` = 5) = sum 6, max 5.
4295                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 6);
4296                assert_eq!(metric.cyclomatic.cyclomatic_max(), 5);
4297                insta::assert_json_snapshot!(
4298                    metric.cyclomatic,
4299                    @r#"
4300                {
4301                  "sum": 6,
4302                  "value": 1,
4303                  "average": 6.0,
4304                  "min": 1,
4305                  "max": 5,
4306                  "modified": {
4307                    "sum": 6,
4308                    "value": 1,
4309                    "average": 6.0,
4310                    "min": 1,
4311                    "max": 5
4312                  }
4313                }
4314                "#
4315                );
4316            },
4317        );
4318    }
4319
4320    #[test]
4321    fn tsx_short_circuit_assignments_248() {
4322        // TSX parallel of #248: `&&=` / `||=` / `??=` each +1.
4323        check_metrics::<TsxParser>(
4324            "function f(x: number | null, y: number | null, z: number | null): number { // +1 (entry)
4325                 x ??= 1; // +1 (??=)
4326                 y &&= 2; // +1 (&&=)
4327                 z ||= 3; // +1 (||=)
4328                 return x ?? 0; // +1 (??)
4329             }",
4330            "foo.tsx",
4331            |metric| {
4332                // unit(1) + fn(entry 1 + 3 op= + 1 `??` = 5) = sum 6, max 5.
4333                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 6);
4334                assert_eq!(metric.cyclomatic.cyclomatic_max(), 5);
4335                insta::assert_json_snapshot!(
4336                    metric.cyclomatic,
4337                    @r#"
4338                {
4339                  "sum": 6,
4340                  "value": 1,
4341                  "average": 6.0,
4342                  "min": 1,
4343                  "max": 5,
4344                  "modified": {
4345                    "sum": 6,
4346                    "value": 1,
4347                    "average": 6.0,
4348                    "min": 1,
4349                    "max": 5
4350                  }
4351                }
4352                "#
4353                );
4354            },
4355        );
4356    }
4357
4358    #[test]
4359    fn mozjs_short_circuit_assignments_248() {
4360        // Mozjs parallel of #248: `&&=` / `||=` / `??=` each +1.
4361        check_metrics::<MozjsParser>(
4362            "function f(x, y, z) { // +1 (entry)
4363                 x ??= 1; // +1 (??=)
4364                 y &&= 2; // +1 (&&=)
4365                 z ||= 3; // +1 (||=)
4366                 return x;
4367             }",
4368            "foo.js",
4369            |metric| {
4370                // unit(1) + fn(entry 1 + 3 assignments = 4) = sum 5, max 4.
4371                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
4372                assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
4373                insta::assert_json_snapshot!(
4374                    metric.cyclomatic,
4375                    @r#"
4376                {
4377                  "sum": 5,
4378                  "value": 1,
4379                  "average": 5.0,
4380                  "min": 1,
4381                  "max": 4,
4382                  "modified": {
4383                    "sum": 5,
4384                    "value": 1,
4385                    "average": 5.0,
4386                    "min": 1,
4387                    "max": 4
4388                  }
4389                }
4390                "#
4391                );
4392            },
4393        );
4394    }
4395
4396    // Issue #281: optional chaining (`?.`) is short-circuit (it skips
4397    // the rest of the chain when the LHS is nullish), so each `?.`
4398    // adds one cyclomatic decision point. Before the fix, JS-family
4399    // cyclomatic ignored `?.` entirely. The four tests below mirror
4400    // the existing `nullish_coalescing_chain_226` pattern but for
4401    // `?.`: two `?.` in a chain add +2 on top of the function entry.
4402    #[test]
4403    fn javascript_optional_chain_counted_in_cyclomatic_281() {
4404        check_metrics::<JavascriptParser>(
4405            "function pick(a) { // +1 (entry)
4406                 return a?.b?.c; // +2 (two `?.`)
4407             }",
4408            "foo.js",
4409            |metric| {
4410                // unit(1) + fn(entry 1 + 2*?. = 3) = sum 4, max 3.
4411                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4412                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4413            },
4414        );
4415    }
4416
4417    #[test]
4418    fn mozjs_optional_chain_counted_in_cyclomatic_281() {
4419        check_metrics::<MozjsParser>(
4420            "function pick(a) { // +1 (entry)
4421                 return a?.b?.c; // +2 (two `?.`)
4422             }",
4423            "foo.js",
4424            |metric| {
4425                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4426                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4427            },
4428        );
4429    }
4430
4431    #[test]
4432    fn typescript_optional_chain_counted_in_cyclomatic_281() {
4433        // TS exposes `?.` as both an `optional_chain` wrapper (over
4434        // member expressions) and a bare token (over call
4435        // expressions). We dispatch on `QMARKDOT` so every textual
4436        // `?.` adds exactly one decision point regardless of context.
4437        check_metrics::<TypescriptParser>(
4438            "function pick(a: any) { // +1 (entry)
4439                 return a?.b?.c; // +2 (two `?.`)
4440             }",
4441            "foo.ts",
4442            |metric| {
4443                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4444                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4445            },
4446        );
4447    }
4448
4449    #[test]
4450    fn tsx_optional_chain_counted_in_cyclomatic_281() {
4451        check_metrics::<TsxParser>(
4452            "function pick(a: any) { // +1 (entry)
4453                 return a?.b?.c; // +2 (two `?.`)
4454             }",
4455            "foo.tsx",
4456            |metric| {
4457                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4458                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4459            },
4460        );
4461    }
4462
4463    // Mix of member-expression `?.` and call-expression `?.()`:
4464    // ensures the TS/TSX dispatch on `QMARKDOT` (not the wrapper)
4465    // counts both forms exactly once. Both forms emit the bare `?.`
4466    // token; the wrapper only appears around member expressions.
4467    #[test]
4468    fn typescript_optional_chain_call_form_counted_281() {
4469        check_metrics::<TypescriptParser>(
4470            "function pick(a: any) { // +1 (entry)
4471                 return a?.b?.(); // +2 (member `?.` + call `?.`)
4472             }",
4473            "foo.ts",
4474            |metric| {
4475                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4476                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4477            },
4478        );
4479    }
4480
4481    #[test]
4482    fn tsx_optional_chain_call_form_counted_281() {
4483        check_metrics::<TsxParser>(
4484            "function pick(a: any) { // +1 (entry)
4485                 return a?.b?.(); // +2 (member `?.` + call `?.`)
4486             }",
4487            "foo.tsx",
4488            |metric| {
4489                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4490                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4491            },
4492        );
4493    }
4494
4495    #[test]
4496    fn csharp_nullish_coalescing_assignment_231() {
4497        // C#'s `??=` is short-circuit (RHS evaluates only when LHS is null)
4498        // and must add +1 cyclomatic per occurrence (#231).
4499        check_metrics::<CsharpParser>(
4500            "public class A {
4501                public int? x;
4502                public int? y;
4503                public void Pick() { // +1 (entry)
4504                    x ??= 1; // +1 (??=)
4505                    y ??= 2; // +1 (??=)
4506                }
4507            }",
4508            "foo.cs",
4509            |metric| {
4510                // unit(1) + class(1) + Pick(entry 1 + 2*??= = 3) = sum 5,
4511                // max 3 (Pick).
4512                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
4513                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4514                insta::assert_json_snapshot!(
4515                    metric.cyclomatic,
4516                    @r#"
4517                {
4518                  "sum": 5,
4519                  "value": 1,
4520                  "average": 5.0,
4521                  "min": 1,
4522                  "max": 3,
4523                  "modified": {
4524                    "sum": 5,
4525                    "value": 1,
4526                    "average": 5.0,
4527                    "min": 1,
4528                    "max": 3
4529                  }
4530                }
4531                "#
4532                );
4533            },
4534        );
4535    }
4536
4537    #[test]
4538    fn mozjs_while_loop() {
4539        check_metrics::<MozjsParser>(
4540            "function f(n) { // +2 (+1 unit)
4541             var i = 0;
4542             while (i < n) { // +1
4543                 i++;
4544             }
4545             return i;
4546         }",
4547            "foo.js",
4548            |metric| {
4549                // unit(1) + fn(base 1 + while 1) = sum 3, max 2.
4550                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4551                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4552                insta::assert_json_snapshot!(metric.cyclomatic);
4553            },
4554        );
4555    }
4556
4557    #[test]
4558    fn bash_while_loop() {
4559        check_metrics::<BashParser>(
4560            "#!/bin/bash
4561f() {
4562    local n=$1
4563    while [ $n -gt 0 ]; do
4564        echo $n
4565        n=$((n - 1))
4566    done
4567}",
4568            "foo.sh",
4569            |metric| {
4570                // unit(1) + fn(base 1 + while 1) = sum 3, max 2.
4571                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4572                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4573                insta::assert_json_snapshot!(metric.cyclomatic);
4574            },
4575        );
4576    }
4577
4578    #[test]
4579    fn bash_case_statement() {
4580        check_metrics::<BashParser>(
4581            "#!/bin/bash
4582f() {
4583    case $1 in
4584        start) echo starting ;;
4585        stop)  echo stopping ;;
4586        *)     echo unknown  ;;
4587    esac
4588}",
4589            "foo.sh",
4590            |metric| {
4591                // standard: unit(1) + fn(base 1 + 2 explicit case_items;
4592                //          `*)` skipped per #211) = sum 4, max 3.
4593                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4594                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4595                insta::assert_json_snapshot!(metric.cyclomatic);
4596            },
4597        );
4598    }
4599
4600    /// Regression #211: a bare `*)` arm is Bash's analogue of the
4601    /// C-family `default:` and must NOT contribute to standard CCN.
4602    /// Without the fix, this 2-arm case reports `cyclomatic_max == 3`
4603    /// (1 base + 2 arms); with the fix it reports `2` (1 base + 1
4604    /// explicit arm), matching every other switch-bearing language
4605    /// in `tests/parity/cyclomatic_cross_language_parity.rs`.
4606    #[test]
4607    fn bash_case_bare_wildcard_excluded() {
4608        check_metrics::<BashParser>(
4609            "#!/bin/bash
4610f() {
4611    case \"$1\" in
4612        one) echo 1 ;;
4613        *)   echo 0 ;;
4614    esac
4615}",
4616            "foo.sh",
4617            |metric| {
4618                // standard: unit(1) + fn(base 1 + 1 explicit; `*)` skipped) = 3, max 2.
4619                // modified: unit(1) + fn(base 1 + case_stmt 1) = 3, max 2.
4620                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4621                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4622                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
4623                assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 2);
4624                insta::assert_json_snapshot!(metric.cyclomatic);
4625            },
4626        );
4627    }
4628
4629    /// A multi-value pattern containing `*` (`a|*)`) is NOT a bare
4630    /// wildcard — both alternations make it a non-default case. The
4631    /// arm still contributes one standard decision.
4632    #[test]
4633    fn bash_case_multi_value_with_star_counts() {
4634        check_metrics::<BashParser>(
4635            "#!/bin/bash
4636f() {
4637    case \"$1\" in
4638        a|*) echo any ;;
4639    esac
4640}",
4641            "foo.sh",
4642            |metric| {
4643                // standard: unit(1) + fn(base 1 + 1 arm) = 3, max 2.
4644                // The `a|*` pattern has TWO `value` fields, so the
4645                // bare-wildcard filter (`value_count == 1`) skips it.
4646                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4647                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4648            },
4649        );
4650    }
4651
4652    #[test]
4653    fn bash_simple_function() {
4654        check_metrics::<BashParser>(
4655            "#!/bin/bash
4656f() {
4657    echo hello
4658}",
4659            "foo.sh",
4660            |metric| {
4661                // unit(1) + fn(base 1) = sum 2, max 1.
4662                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 2);
4663                assert_eq!(metric.cyclomatic.cyclomatic_max(), 1);
4664                insta::assert_json_snapshot!(metric.cyclomatic);
4665            },
4666        );
4667    }
4668
4669    #[test]
4670    fn kotlin_for_loop() {
4671        check_metrics::<KotlinParser>(
4672            "fun sum(n: Int): Int {  // +2 (+1 unit)
4673             var s = 0
4674             for (i in 1..n) {  // +1
4675                 s += i
4676             }
4677             return s
4678         }",
4679            "foo.kt",
4680            |metric| {
4681                // unit(1) + fn(base 1 + for 1) = sum 3, max 2.
4682                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4683                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4684                insta::assert_json_snapshot!(metric.cyclomatic);
4685            },
4686        );
4687    }
4688
4689    #[test]
4690    fn kotlin_while_loop() {
4691        check_metrics::<KotlinParser>(
4692            "fun countdown(n: Int): Int { // +2 (+1 unit)
4693             var i = n
4694             while (i > 0) { // +1
4695                 i--
4696             }
4697             return i
4698         }",
4699            "foo.kt",
4700            |metric| {
4701                // unit(1) + fn(base 1 + while 1) = sum 3, max 2.
4702                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4703                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4704                insta::assert_json_snapshot!(metric.cyclomatic);
4705            },
4706        );
4707    }
4708
4709    #[test]
4710    fn kotlin_logical_operators() {
4711        check_metrics::<KotlinParser>(
4712            "fun check(a: Boolean, b: Boolean, c: Boolean): Boolean { // +2 (+1 unit)
4713             return a && b || c  // +1 &&, +1 ||
4714         }",
4715            "foo.kt",
4716            |metric| {
4717                // unit(1) + fn(base 1 + && 1 + || 1) = sum 4, max 3.
4718                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4719                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4720                insta::assert_json_snapshot!(metric.cyclomatic);
4721            },
4722        );
4723    }
4724
4725    #[test]
4726    fn kotlin_elvis_operator_239() {
4727        // Regression for issue #239: Kotlin's Elvis operator `?:` is a
4728        // short-circuit nullish operator analogous to JS `??` and each
4729        // occurrence is a distinct decision point, mirroring `&&` /
4730        // `||`. `a ?: b ?: c` contributes +2 to the function's
4731        // cyclomatic complexity (base 1 + two `?:` = 3).
4732        check_metrics::<KotlinParser>(
4733            "fun pick(a: String?, b: String?, c: String): String { // +2 (+1 unit)
4734             return a ?: b ?: c  // +2 (two ?: short-circuits)
4735         }",
4736            "foo.kt",
4737            |metric| {
4738                // unit(1) + fn(base 1 + ?: 1 + ?: 1) = sum 4, max 3.
4739                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4740                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4741                insta::assert_json_snapshot!(
4742                    metric.cyclomatic,
4743                    @r#"
4744                {
4745                  "sum": 4,
4746                  "value": 1,
4747                  "average": 4.0,
4748                  "min": 1,
4749                  "max": 3,
4750                  "modified": {
4751                    "sum": 4,
4752                    "value": 1,
4753                    "average": 4.0,
4754                    "min": 1,
4755                    "max": 3
4756                  }
4757                }
4758                "#
4759                );
4760            },
4761        );
4762    }
4763
4764    #[test]
4765    fn kotlin_safe_navigation_436() {
4766        // Issue #436: Kotlin's safe-navigation `?.` is a short-circuit
4767        // decision point, mirroring the JS/TS/C# treatment of `?.`
4768        // (#281). Each `?.` adds +1; the chain `a?.b?.c` adds +2.
4769        check_metrics::<KotlinParser>(
4770            "fun read(a: A?): String? { // +2 (+1 unit)
4771             return a?.b?.c  // +2 (two ?. short-circuits)
4772         }",
4773            "foo.kt",
4774            |metric| {
4775                // unit(1) + fn(base 1 + ?. 1 + ?. 1) = sum 4, max 3.
4776                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4777                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4778                // modified mirrors standard: each `?.` is both-metric.
4779                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
4780                assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 3);
4781            },
4782        );
4783    }
4784
4785    #[test]
4786    fn typescript_for_loop() {
4787        check_metrics::<TypescriptParser>(
4788            "function sum(n: number): number { // +2 (+1 unit)
4789             let s = 0;
4790             for (let i = 0; i < n; i++) { // +1
4791                 s += i;
4792             }
4793             return s;
4794         }",
4795            "foo.ts",
4796            |metric| {
4797                // unit(1) + fn(base 1 + for 1) = sum 3, max 2.
4798                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4799                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4800                insta::assert_json_snapshot!(metric.cyclomatic);
4801            },
4802        );
4803    }
4804
4805    #[test]
4806    fn typescript_while_loop() {
4807        check_metrics::<TypescriptParser>(
4808            "function countdown(n: number): number { // +2 (+1 unit)
4809             let i = n;
4810             while (i > 0) { // +1
4811                 i--;
4812             }
4813             return i;
4814         }",
4815            "foo.ts",
4816            |metric| {
4817                // unit(1) + fn(base 1 + while 1) = sum 3, max 2.
4818                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4819                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4820                insta::assert_json_snapshot!(metric.cyclomatic);
4821            },
4822        );
4823    }
4824
4825    #[test]
4826    fn typescript_logical_operators() {
4827        check_metrics::<TypescriptParser>(
4828            "function check(a: boolean, b: boolean, c: boolean): boolean { // +2 (+1 unit)
4829             return a && b || c;  // +1 &&, +1 ||
4830         }",
4831            "foo.ts",
4832            |metric| {
4833                // unit(1) + fn(base 1 + && 1 + || 1) = sum 4, max 3.
4834                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4835                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4836                insta::assert_json_snapshot!(metric.cyclomatic);
4837            },
4838        );
4839    }
4840
4841    #[test]
4842    fn typescript_try_catch() {
4843        check_metrics::<TypescriptParser>(
4844            "function safe(x: number): number { // +2 (+1 unit)
4845             try {
4846                 return 1 / x;
4847             } catch (e) { // +1
4848                 return 0;
4849             }
4850         }",
4851            "foo.ts",
4852            |metric| {
4853                // unit(1) + fn(base 1 + catch 1) = sum 3, max 2.
4854                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4855                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4856                insta::assert_json_snapshot!(metric.cyclomatic);
4857            },
4858        );
4859    }
4860
4861    #[test]
4862    fn tsx_for_loop() {
4863        check_metrics::<TsxParser>(
4864            "function sum(n: number): number { // +2 (+1 unit)
4865             let s = 0;
4866             for (let i = 0; i < n; i++) { // +1
4867                 s += i;
4868             }
4869             return s;
4870         }",
4871            "foo.tsx",
4872            |metric| {
4873                // unit(1) + fn(base 1 + for 1) = sum 3, max 2.
4874                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4875                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4876                insta::assert_json_snapshot!(metric.cyclomatic);
4877            },
4878        );
4879    }
4880
4881    #[test]
4882    fn tsx_while_loop() {
4883        check_metrics::<TsxParser>(
4884            "function countdown(n: number): number { // +2 (+1 unit)
4885             let i = n;
4886             while (i > 0) { // +1
4887                 i--;
4888             }
4889             return i;
4890         }",
4891            "foo.tsx",
4892            |metric| {
4893                // unit(1) + fn(base 1 + while 1) = sum 3, max 2.
4894                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4895                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4896                insta::assert_json_snapshot!(metric.cyclomatic);
4897            },
4898        );
4899    }
4900
4901    #[test]
4902    fn tsx_logical_operators() {
4903        check_metrics::<TsxParser>(
4904            "function check(a: boolean, b: boolean, c: boolean): boolean { // +2 (+1 unit)
4905             return a && b || c;  // +1 &&, +1 ||
4906         }",
4907            "foo.tsx",
4908            |metric| {
4909                // unit(1) + fn(base 1 + && 1 + || 1) = sum 4, max 3.
4910                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4911                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4912                insta::assert_json_snapshot!(metric.cyclomatic);
4913            },
4914        );
4915    }
4916
4917    #[test]
4918    fn tsx_try_catch() {
4919        check_metrics::<TsxParser>(
4920            "function safe(x: number): number { // +2 (+1 unit)
4921             try {
4922                 return 1 / x;
4923             } catch (e) { // +1
4924                 return 0;
4925             }
4926         }",
4927            "foo.tsx",
4928            |metric| {
4929                // unit(1) + fn(base 1 + catch 1) = sum 3, max 2.
4930                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4931                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4932                insta::assert_json_snapshot!(metric.cyclomatic);
4933            },
4934        );
4935    }
4936
4937    #[test]
4938    fn tsx_switch() {
4939        check_metrics::<TsxParser>(
4940            "function describe(x: number): string { // +2 (+1 unit)
4941             switch (x) {
4942                 case 1: // +1
4943                     return 'one';
4944                 case 2: // +1
4945                     return 'two';
4946                 default:
4947                     return 'other';
4948             }
4949         }",
4950            "foo.tsx",
4951            |metric| {
4952                // unit(1) + fn(base 1 + 2 cases) = sum 4, max 3.
4953                // default does NOT add a branch.
4954                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4955                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4956                insta::assert_json_snapshot!(metric.cyclomatic);
4957            },
4958        );
4959    }
4960
4961    /// Modified CCN: TSX switch with 2 cases collapses to 1.
4962    #[test]
4963    fn tsx_switch_modified() {
4964        check_metrics::<TsxParser>(
4965            "function f(x: number): string {
4966                 switch (x) {
4967                     case 1: return 'one';
4968                     case 2: return 'two';
4969                     default: return 'other';
4970                 }
4971             }",
4972            "foo.tsx",
4973            |metric| {
4974                // standard: unit(1) + fn(1) + 2 cases = sum 4, max 3.
4975                // modified: unit(1) + fn(1) + switch(1) = sum 3, max 2.
4976                // default does NOT add a branch.
4977                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4978                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4979                insta::assert_json_snapshot!(metric.cyclomatic);
4980            },
4981        );
4982    }
4983
4984    #[test]
4985    fn php_1_level_nesting() {
4986        // Mirrors java_simple_class' if-inside-method shape:
4987        // unit (+1) + function (+1) + if (+1) + && (+1) = sum 4.
4988        check_metrics::<PhpParser>(
4989            "<?php
4990            function f(int $a, int $b): bool {
4991                if ($a > 0 && $b > 0) {
4992                    return true;
4993                }
4994                return false;
4995            }",
4996            "foo.php",
4997            |metric| {
4998                insta::assert_json_snapshot!(
4999                    metric.cyclomatic,
5000                    @r#"
5001                {
5002                  "sum": 4,
5003                  "value": 1,
5004                  "average": 4.0,
5005                  "min": 1,
5006                  "max": 3,
5007                  "modified": {
5008                    "sum": 4,
5009                    "value": 1,
5010                    "average": 4.0,
5011                    "min": 1,
5012                    "max": 3
5013                  }
5014                }
5015                "#
5016                );
5017            },
5018        );
5019    }
5020
5021    // `case`/`cond`/`with` arms surface as `stab_clause` nodes and
5022    // contribute to standard CCN, mirroring the C-family `case:` arm
5023    // treatment. The container Call (`case`) contributes once to
5024    // modified CCN, collapsing arms back to a single decision point.
5025    // Three func spaces (Unit + defmodule Class + def Function) each
5026    // seed one entry: standard = 3 entries + 3 stabs = 6; modified =
5027    // 3 entries + 1 case Call = 4.
5028    #[test]
5029    fn elixir_case_arms() {
5030        check_metrics::<ElixirParser>(
5031            "defmodule Foo do\n  def classify(x) do\n    case x do\n      1 -> :one\n      2 -> :two\n      _ -> :other\n    end\n  end\nend\n",
5032            "foo.ex",
5033            |metric| {
5034                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 6);
5035                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5036            },
5037        );
5038    }
5039
5040    // Each short-circuit boolean (`&&`, `||`, `and`, `or`) is one
5041    // decision point — Elixir does not expose `if`/`unless` as a
5042    // distinct kind_id, so this is the only operator-driven path the
5043    // metric can see.
5044    #[test]
5045    fn elixir_logical_operators() {
5046        check_metrics::<ElixirParser>(
5047            "defmodule Foo do\n  def f(x, y) do\n    x and y or (x && y) || x\n  end\nend\n",
5048            "foo.ex",
5049            |metric| {
5050                // 4 short-circuit ops + 3 entries (Unit, defmodule, def) = 7.
5051                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 7);
5052                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 7);
5053            },
5054        );
5055    }
5056
5057    // `try`/`rescue`/`catch` is a multi-arm container Call: the `try`
5058    // Call contributes once to modified CCN, while each rescue/catch
5059    // arm's matched pattern (a `stab_clause`) contributes once to
5060    // standard CCN. This mirrors C-family `try`/`catch` semantics.
5061    #[test]
5062    fn elixir_try_rescue() {
5063        check_metrics::<ElixirParser>(
5064            "defmodule Foo do\n  def safe do\n    try do\n      do_it()\n    rescue\n      ArgumentError -> :bad\n    end\n  end\nend\n",
5065            "foo.ex",
5066            |metric| {
5067                // standard: 3 entries + 1 rescue stab = 4
5068                // modified: 3 entries + 1 try Call = 4
5069                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
5070                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5071            },
5072        );
5073    }
5074
5075    // `if x do ... else ... end` surfaces as a `Call(target=if)`; the
5076    // metric inspects the source text of the call's target field to
5077    // identify it. Single-branch keyword Calls (`if`/`unless`/`for`/
5078    // `while`) contribute to both standard and modified CCN.
5079    #[test]
5080    fn elixir_if_else_counts() {
5081        check_metrics::<ElixirParser>(
5082            "defmodule Foo do\n  def f(x) do\n    if x > 0 do\n      :pos\n    else\n      :neg\n    end\n  end\nend\n",
5083            "foo.ex",
5084            |metric| {
5085                // 1 if Call + 3 entries (Unit, defmodule Class, def Function) = 4.
5086                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
5087                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5088            },
5089        );
5090    }
5091
5092    // `if x do ... end` without an `else` clause still surfaces as
5093    // `Call(target=if)` and is counted identically to the if/else
5094    // form — the `else` keyword is a do-block keyword argument, not
5095    // an extra `stab_clause`, so its presence does not change the
5096    // cyclomatic count.
5097    #[test]
5098    fn elixir_if_without_else_counts() {
5099        check_metrics::<ElixirParser>(
5100            "defmodule Foo do\n  def f(x) do\n    if x > 0 do\n      :pos\n    end\n  end\nend\n",
5101            "foo.ex",
5102            |metric| {
5103                // 1 if Call + 3 entries = 4.
5104                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
5105                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5106            },
5107        );
5108    }
5109
5110    // `unless x do ... end` is the negated `if`; it surfaces as
5111    // `Call(target=unless)` and is treated identically to `if`.
5112    #[test]
5113    fn elixir_unless_counts() {
5114        check_metrics::<ElixirParser>(
5115            "defmodule Foo do\n  def f(x) do\n    unless x > 0 do\n      :nonpos\n    end\n  end\nend\n",
5116            "foo.ex",
5117            |metric| {
5118                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
5119                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5120            },
5121        );
5122    }
5123
5124    // `for x <- list, do: ...` is Elixir's comprehension generator —
5125    // a `Call(target=for)`. Counts once for both standard and
5126    // modified, mirroring `if`/`unless`.
5127    #[test]
5128    fn elixir_for_comprehension_counts() {
5129        check_metrics::<ElixirParser>(
5130            "defmodule Foo do\n  def f(xs) do\n    for x <- xs do\n      x * 2\n    end\n  end\nend\n",
5131            "foo.ex",
5132            |metric| {
5133                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
5134                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5135            },
5136        );
5137    }
5138
5139    // `fn ... end` is its own function space (`get_space_kind` →
5140    // `Function`), so its cyclomatic gets its own `+1` entry path
5141    // alongside the Unit / defmodule Class / def Function entries.
5142    // The FIRST `stab_clause` is the closure's head/definition and does
5143    // NOT count (issue #776); only the 2nd+ clauses are pattern-dispatch
5144    // branches. The anon-fn itself is not a `Call`, so it adds no
5145    // modified-CCN container decision. Standard = 4 entries (Unit,
5146    // defmodule, def, anon-fn) + 1 branch (2nd clause) = 5; modified =
5147    // 4 entries = 4.
5148    #[test]
5149    fn elixir_anonymous_fn_arms_count() {
5150        check_metrics::<ElixirParser>(
5151            "defmodule Foo do\n  def f do\n    multi = fn 0 -> :zero; _ -> :other end\n    multi.(0)\n  end\nend\n",
5152            "foo.ex",
5153            |metric| {
5154                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
5155                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5156            },
5157        );
5158    }
5159
5160    // Regression for issue #776: a single-clause anonymous function
5161    // (`fn x -> x end`) has zero decision points — its lone
5162    // `stab_clause` is the closure head, not a branch. The closure's
5163    // own function space must therefore report cyclomatic 1 (base
5164    // entry only), matching cognitive's treatment (`cognitive.rs`
5165    // `elixir_enum_reduce_is_zero`). Before the fix the head clause
5166    // added a spurious +1, reporting 2. Standard = 4 entries (Unit,
5167    // defmodule, def, anon-fn) + 0 branches = 4; modified = 4.
5168    #[test]
5169    fn elixir_single_clause_anonymous_fn_is_not_a_branch() {
5170        check_metrics::<ElixirParser>(
5171            "defmodule Foo do\n  def f do\n    id = fn x -> x end\n    id.(1)\n  end\nend\n",
5172            "foo.ex",
5173            |metric| {
5174                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
5175                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5176            },
5177        );
5178    }
5179
5180    // `cond do ... end` is the standard Elixir multi-way conditional.
5181    // Each clause is a `stab_clause` (standard CCN), and the `cond`
5182    // Call is a multi-arm container (modified CCN, once).
5183    #[test]
5184    fn elixir_cond_arms() {
5185        check_metrics::<ElixirParser>(
5186            "defmodule Foo do\n  def f(x) do\n    cond do\n      x < 0 -> :neg\n      x == 0 -> :zero\n      true -> :pos\n    end\n  end\nend\n",
5187            "foo.ex",
5188            |metric| {
5189                // standard: 3 entries + 3 stabs = 6
5190                // modified: 3 entries + 1 cond Call = 4
5191                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 6);
5192                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5193            },
5194        );
5195    }
5196
5197    // `with` chains use `<-` arrows, which parse as `binary_operator`
5198    // nodes — NOT `stab_clause`s — so the `with`-head clauses do not
5199    // contribute to standard CCN per-arm. The fallthrough `else`
5200    // branch, when present, contains `stab_clause`s that count for
5201    // standard. The `with` Call itself is a multi-arm container Call
5202    // that contributes once to modified CCN.
5203    #[test]
5204    fn elixir_with_else_only_counts_else_arms() {
5205        check_metrics::<ElixirParser>(
5206            "defmodule Foo do\n  def f(x) do\n    with {:ok, v} <- fetch(x),\n         {:ok, w} <- fetch(v) do\n      {:ok, w}\n    else\n      :error -> :nope\n      other -> {:bad, other}\n    end\n  end\nend\n",
5207            "foo.ex",
5208            |metric| {
5209                // standard: 3 entries + 2 else-block stabs = 5
5210                // modified: 3 entries + 1 with Call = 4
5211                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
5212                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5213            },
5214        );
5215    }
5216
5217    #[test]
5218    fn php_match_expression() {
5219        // Each `match_conditional_expression` arm (+1) but the default arm
5220        // does NOT add a branch (mirrors switch/case Java semantics).
5221        check_metrics::<PhpParser>(
5222            "<?php
5223            function color(string $c): int {
5224                return match ($c) {
5225                    'red' => 1,
5226                    'green' => 2,
5227                    'blue' => 3,
5228                    default => 0,
5229                };
5230            }",
5231            "foo.php",
5232            |metric| {
5233                // unit (+1) + function (+1) + 3 match arms (+3) = sum 5.
5234                // Default arm contributes 0.
5235                insta::assert_json_snapshot!(
5236                    metric.cyclomatic,
5237                    @r#"
5238                {
5239                  "sum": 5,
5240                  "value": 1,
5241                  "average": 5.0,
5242                  "min": 1,
5243                  "max": 4,
5244                  "modified": {
5245                    "sum": 3,
5246                    "value": 1,
5247                    "average": 3.0,
5248                    "min": 1,
5249                    "max": 2
5250                  }
5251                }
5252                "#
5253                );
5254            },
5255        );
5256    }
5257
5258    /// Modified CCN: PHP switch with 3 cases collapses to 1.
5259    #[test]
5260    fn php_switch_modified() {
5261        check_metrics::<PhpParser>(
5262            "<?php
5263            function describe(int $n): string {
5264                switch ($n) {
5265                    case 1:
5266                        return 'one';
5267                    case 2:
5268                        return 'two';
5269                    case 3:
5270                        return 'three';
5271                    default:
5272                        return 'other';
5273                }
5274            }",
5275            "foo.php",
5276            |metric| {
5277                // standard: unit(1) + fn(1) + 3 cases = sum 5, max 4.
5278                // modified: unit(1) + fn(1) + switch(1) = sum 3, max 2.
5279                // default does NOT add a branch.
5280                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
5281                assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
5282                insta::assert_json_snapshot!(metric.cyclomatic);
5283            },
5284        );
5285    }
5286
5287    #[test]
5288    fn php_null_coalescing() {
5289        // `??` and `??=` are each one short-circuit decision (#231).
5290        // Tree-sitter emits `??=` as the single token `QMARKQMARKEQ`, so it
5291        // is matched independently from the binary `??`.
5292        check_metrics::<PhpParser>(
5293            "<?php
5294            function pick($x, $y) {
5295                $a = $x ?? $y;
5296                $a ??= 0;
5297                return $a;
5298            }",
5299            "foo.php",
5300            |metric| {
5301                // unit (+1) + function (+1) + ?? (+1) + ??= (+1) = sum 4.
5302                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
5303                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
5304                insta::assert_json_snapshot!(
5305                    metric.cyclomatic,
5306                    @r#"
5307                {
5308                  "sum": 4,
5309                  "value": 1,
5310                  "average": 4.0,
5311                  "min": 1,
5312                  "max": 3,
5313                  "modified": {
5314                    "sum": 4,
5315                    "value": 1,
5316                    "average": 4.0,
5317                    "min": 1,
5318                    "max": 3
5319                  }
5320                }
5321                "#
5322                );
5323            },
5324        );
5325    }
5326
5327    #[test]
5328    fn php_nullsafe_operator_436() {
5329        // Issue #436: PHP's nullsafe operator `?->` is a short-circuit
5330        // decision point, mirroring the JS/TS/C# treatment of `?.`
5331        // (#281). The `QMARKDASHGT` token fires once per operator across
5332        // both property access (`$a?->b`) and method call (`$a?->c()`),
5333        // and once per link in a chain. Here: one access + one chained
5334        // call (`$a?->b?->c()`) = +2 for that statement.
5335        check_metrics::<PhpParser>(
5336            "<?php
5337            function read($a) {
5338                return $a?->b?->c();
5339            }",
5340            "foo.php",
5341            |metric| {
5342                // unit(1) + fn(base 1 + ?-> 1 + ?-> 1) = sum 4, max 3.
5343                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
5344                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
5345                // modified mirrors standard: each `?->` is both-metric.
5346                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5347                assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 3);
5348            },
5349        );
5350    }
5351
5352    /// Modified CCN: nested switches contribute one decision each, not one
5353    /// total — the outer container does not absorb the inner one.
5354    #[test]
5355    fn cpp_nested_switch_modified() {
5356        check_metrics::<CppParser>(
5357            "void f() {
5358                 switch (x) {
5359                     case 1:
5360                         switch (y) {
5361                             case 10: break;
5362                             case 20: break;
5363                         }
5364                         break;
5365                     case 2: break;
5366                 }
5367             }",
5368            "foo.c",
5369            |metric| {
5370                // standard: unit(1) + fn(1) + 4 cases  = 6
5371                // modified: unit(1) + fn(1) + 2 switches = 4
5372                insta::assert_json_snapshot!(
5373                    metric.cyclomatic,
5374                    @r#"
5375                {
5376                  "sum": 6,
5377                  "value": 1,
5378                  "average": 6.0,
5379                  "min": 1,
5380                  "max": 5,
5381                  "modified": {
5382                    "sum": 4,
5383                    "value": 1,
5384                    "average": 4.0,
5385                    "min": 1,
5386                    "max": 3
5387                  }
5388                }
5389                "#
5390                );
5391            },
5392        );
5393    }
5394
5395    /// Modified CCN: nested Rust matches each contribute one container.
5396    /// Bare `_ =>` arms are skipped.
5397    #[test]
5398    fn rust_nested_match_modified() {
5399        check_metrics::<RustParser>(
5400            "fn f(x: u8) -> u8 {
5401                 match x {
5402                     1 => match x {
5403                         10 => 1,
5404                         20 => 2,
5405                         _ => 0,
5406                     },
5407                     _ => 0,
5408                 }
5409             }",
5410            "foo.rs",
5411            |metric| {
5412                // standard: unit(1) + fn(1) + 3 arms (1,10,20; both _ skipped) = 5
5413                // modified: unit(1) + fn(1) + 2 matches  = 4
5414                insta::assert_json_snapshot!(
5415                    metric.cyclomatic,
5416                    @r#"
5417                {
5418                  "sum": 5,
5419                  "value": 1,
5420                  "average": 5.0,
5421                  "min": 1,
5422                  "max": 4,
5423                  "modified": {
5424                    "sum": 4,
5425                    "value": 1,
5426                    "average": 4.0,
5427                    "min": 1,
5428                    "max": 3
5429                  }
5430                }
5431                "#
5432                );
5433            },
5434        );
5435    }
5436
5437    /// Pin the empty-switch edge case: standard counts no arms (0) while
5438    /// modified still counts the container (+1) per Lizard's `-m`.
5439    #[test]
5440    fn cpp_empty_switch_modified() {
5441        check_metrics::<CppParser>("void f() { switch (x) {} }", "foo.c", |metric| {
5442            // standard: unit(1) + fn(1) + 0 cases    = 2
5443            // modified: unit(1) + fn(1) + 1 switch   = 3
5444            insta::assert_json_snapshot!(
5445                metric.cyclomatic,
5446                @r#"
5447            {
5448              "sum": 2,
5449              "value": 1,
5450              "average": 2.0,
5451              "min": 1,
5452              "max": 1,
5453              "modified": {
5454                "sum": 3,
5455                "value": 1,
5456                "average": 3.0,
5457                "min": 1,
5458                "max": 2
5459              }
5460            }
5461            "#
5462            );
5463        });
5464    }
5465
5466    /// Two nested `for` loops contribute +1 each on top of the function and
5467    /// unit decisions.  No condition expressions, so `&&` / `||` do not fire.
5468    #[test]
5469    fn c_nested_loops() {
5470        check_metrics::<CParser>(
5471            "void f() {
5472                 for (int i = 0; i < 10; ++i) {     // +1
5473                     for (int j = 0; j < 10; ++j) { // +1
5474                         g(i, j);
5475                     }
5476                 }
5477             }",
5478            "foo.c",
5479            |metric| {
5480                // standard: unit(1) + fn(1) + 2 for = 4
5481                // modified: identical (no switch container, no extra arms)
5482                let s = &metric.cyclomatic;
5483                assert_eq!(s.cyclomatic_sum(), 4);
5484                assert_eq!(s.cyclomatic_max(), 3);
5485                assert_eq!(s.cyclomatic_modified_sum(), 4);
5486                insta::assert_json_snapshot!(
5487                    metric.cyclomatic,
5488                    @r#"
5489                {
5490                  "sum": 4,
5491                  "value": 1,
5492                  "average": 4.0,
5493                  "min": 1,
5494                  "max": 3,
5495                  "modified": {
5496                    "sum": 4,
5497                    "value": 1,
5498                    "average": 4.0,
5499                    "min": 1,
5500                    "max": 3
5501                  }
5502                }
5503                "#
5504                );
5505            },
5506        );
5507    }
5508
5509    /// C++ `do { … } while (…)` contributes exactly +1 to both
5510    /// standard and modified CCN. The +1 comes from the `while`
5511    /// keyword token inside the do-statement (`Cpp::While`), which the
5512    /// C-family macro already counts. Adding the `DoStatement`
5513    /// statement node would double-count — see the macro doc comment
5514    /// and issue #284. This test pins the correct keyword-driven
5515    /// count.
5516    #[test]
5517    fn cpp_do_statement_counts_in_cyclomatic() {
5518        check_metrics::<CppParser>(
5519            "void f() {
5520                 int i = 0;
5521                 do {           // +1 (via inner `while` keyword)
5522                     ++i;
5523                 } while (i < 10);
5524             }",
5525            "foo.cpp",
5526            |metric| {
5527                // standard: unit(1) + fn(1) + do(1) = 3
5528                // modified: identical (no switch, no extra arms)
5529                let s = &metric.cyclomatic;
5530                assert_eq!(s.cyclomatic_sum(), 3);
5531                assert_eq!(s.cyclomatic_max(), 2);
5532                assert_eq!(s.cyclomatic_modified_sum(), 3);
5533                insta::assert_json_snapshot!(
5534                    metric.cyclomatic,
5535                    @r#"
5536                {
5537                  "sum": 3,
5538                  "value": 1,
5539                  "average": 3.0,
5540                  "min": 1,
5541                  "max": 2,
5542                  "modified": {
5543                    "sum": 3,
5544                    "value": 1,
5545                    "average": 3.0,
5546                    "min": 1,
5547                    "max": 2
5548                  }
5549                }
5550                "#
5551                );
5552            },
5553        );
5554    }
5555
5556    /// C++ range-based `for (auto x : xs)` contributes exactly +1 to
5557    /// both standard and modified CCN — the `for` keyword token
5558    /// (`Cpp::For`) fires inside the `ForRangeLoop` node just like
5559    /// inside a classic `ForStatement`. Pinning this prevents
5560    /// reintroducing the double-count from issue #284's incorrect fix
5561    /// proposal.
5562    #[test]
5563    fn cpp_for_range_loop_counts_in_cyclomatic() {
5564        check_metrics::<CppParser>(
5565            "void f(std::vector<int> xs) {
5566                 for (auto x : xs) {   // +1 (via `for` keyword)
5567                     g(x);
5568                 }
5569             }",
5570            "foo.cpp",
5571            |metric| {
5572                // standard: unit(1) + fn(1) + for-range(1) = 3
5573                let s = &metric.cyclomatic;
5574                assert_eq!(s.cyclomatic_sum(), 3);
5575                assert_eq!(s.cyclomatic_max(), 2);
5576                assert_eq!(s.cyclomatic_modified_sum(), 3);
5577                insta::assert_json_snapshot!(
5578                    metric.cyclomatic,
5579                    @r#"
5580                {
5581                  "sum": 3,
5582                  "value": 1,
5583                  "average": 3.0,
5584                  "min": 1,
5585                  "max": 2,
5586                  "modified": {
5587                    "sum": 3,
5588                    "value": 1,
5589                    "average": 3.0,
5590                    "min": 1,
5591                    "max": 2
5592                  }
5593                }
5594                "#
5595                );
5596            },
5597        );
5598    }
5599
5600    /// Decision kinds through the dedicated `LANG::C` grammar (#721):
5601    /// `if`, `for`, `while`, `case`, and the `&&` short-circuit each
5602    /// add +1; `switch` adds only to the modified count. C has no
5603    /// `catch`, so the hand-written `Cyclomatic for CCode` impl omits
5604    /// the exception arm the C++ macro carries.
5605    #[test]
5606    fn c_grammar_decision_kinds_count_in_cyclomatic() {
5607        check_metrics::<CParser>(
5608            "int f(int a, int b) {
5609                 if (a && b) {          // +1 if, +1 &&
5610                     return 1;
5611                 }
5612                 for (int i = 0; i < a; ++i) {  // +1 for
5613                     b += i;
5614                 }
5615                 switch (b) {           // +1 modified only
5616                     case 0: return 0;  // +1 case
5617                     default: return b;
5618                 }
5619             }",
5620            "foo.c",
5621            |metric| {
5622                let s = &metric.cyclomatic;
5623                // standard: unit(1) + fn(1) + if(1) + &&(1) + for(1) + case(1) = 6
5624                assert_eq!(s.cyclomatic_sum(), 6);
5625                // modified: `case` adds to standard only and `switch` to
5626                // modified only, so they balance — base(2) + if + && + for
5627                // + switch(1) = 6.
5628                assert_eq!(s.cyclomatic_modified_sum(), 6);
5629            },
5630        );
5631    }
5632
5633    /// `?:` ternary is matched by `Cpp::ConditionalExpression` in the
5634    /// C-family macro and contributes +1 standard *and* +1 modified.
5635    /// Two nested ternaries in one expression therefore add 2 to each.
5636    #[test]
5637    fn c_ternary_chain() {
5638        check_metrics::<CParser>(
5639            "int f(int a, int b, int c) {
5640                 return a > 0 ? a : (b > 0 ? b : c); // +2 ternaries (?: each)
5641             }",
5642            "foo.c",
5643            |metric| {
5644                // standard: unit(1) + fn(1) + 2 ?: = 4
5645                let s = &metric.cyclomatic;
5646                assert_eq!(s.cyclomatic_sum(), 4);
5647                assert_eq!(s.cyclomatic_max(), 3);
5648                assert_eq!(s.cyclomatic_modified_sum(), 4);
5649                insta::assert_json_snapshot!(
5650                    metric.cyclomatic,
5651                    @r#"
5652                {
5653                  "sum": 4,
5654                  "value": 1,
5655                  "average": 4.0,
5656                  "min": 1,
5657                  "max": 3,
5658                  "modified": {
5659                    "sum": 4,
5660                    "value": 1,
5661                    "average": 4.0,
5662                    "min": 1,
5663                    "max": 3
5664                  }
5665                }
5666                "#
5667                );
5668            },
5669        );
5670    }
5671
5672    /// Short-circuit `&&` / `||` chains each contribute +1 — every binary
5673    /// operator token in the chain is a separate decision (Lizard parity).
5674    #[test]
5675    fn c_short_circuit_chain() {
5676        check_metrics::<CParser>(
5677            "int f(int a, int b, int c, int d) {
5678                 if (a && b || c && d) {            // 3 logical ops + 1 if = 4
5679                     return 1;
5680                 }
5681                 return 0;
5682             }",
5683            "foo.c",
5684            |metric| {
5685                // standard: unit(1) + fn(1) + if(1) + && (2) + || (1) = 6
5686                let s = &metric.cyclomatic;
5687                assert_eq!(s.cyclomatic_sum(), 6);
5688                assert_eq!(s.cyclomatic_max(), 5);
5689                assert_eq!(s.cyclomatic_modified_sum(), 6);
5690                insta::assert_json_snapshot!(
5691                    metric.cyclomatic,
5692                    @r#"
5693                {
5694                  "sum": 6,
5695                  "value": 1,
5696                  "average": 6.0,
5697                  "min": 1,
5698                  "max": 5,
5699                  "modified": {
5700                    "sum": 6,
5701                    "value": 1,
5702                    "average": 6.0,
5703                    "min": 1,
5704                    "max": 5
5705                  }
5706                }
5707                "#
5708                );
5709            },
5710        );
5711    }
5712
5713    /// Switch with intentional fall-through: every `case` adds +1 standard
5714    /// regardless of whether the arm `break`s.  Modified collapses all three
5715    /// arms into one switch container.
5716    #[test]
5717    fn c_switch_fallthrough() {
5718        check_metrics::<CParser>(
5719            "int f(int x) {
5720                 int r = 0;
5721                 switch (x) {
5722                     case 1:                // +1
5723                     case 2:                // +1
5724                         r = 10;
5725                         break;
5726                     case 3:                // +1
5727                         r = 20;
5728                         break;
5729                 }
5730                 return r;
5731             }",
5732            "foo.c",
5733            |metric| {
5734                // standard: unit(1) + fn(1) + 3 cases = 5
5735                // modified: unit(1) + fn(1) + 1 switch container = 3
5736                let s = &metric.cyclomatic;
5737                assert_eq!(s.cyclomatic_sum(), 5);
5738                assert_eq!(s.cyclomatic_modified_sum(), 3);
5739                assert!(s.cyclomatic_modified_sum() < s.cyclomatic_sum());
5740                insta::assert_json_snapshot!(
5741                    metric.cyclomatic,
5742                    @r#"
5743                {
5744                  "sum": 5,
5745                  "value": 1,
5746                  "average": 5.0,
5747                  "min": 1,
5748                  "max": 4,
5749                  "modified": {
5750                    "sum": 3,
5751                    "value": 1,
5752                    "average": 3.0,
5753                    "min": 1,
5754                    "max": 2
5755                  }
5756                }
5757                "#
5758                );
5759            },
5760        );
5761    }
5762
5763    /// `goto` is not a recognised decision keyword in the C-family macro
5764    /// (only `If | For | While | Catch | ConditionalExpression | && | ||`
5765    /// add complexity, plus `Case` / `SwitchStatement`).  The label and the
5766    /// `goto` jump are control-flow, but the metric deliberately mirrors
5767    /// Lizard, which also does not count `goto`.  This test pins that
5768    /// decision so a future change that adds `Cpp::GotoStatement` to the
5769    /// macro fires here first.
5770    #[test]
5771    fn c_goto_not_counted() {
5772        check_metrics::<CParser>(
5773            "int f(int n) {
5774                 int i = 0;
5775             retry:
5776                 if (i < n) {     // +1
5777                     ++i;
5778                     goto retry;  // ignored
5779                 }
5780                 return i;
5781             }",
5782            "foo.c",
5783            |metric| {
5784                // standard: unit(1) + fn(1) + if(1) = 3
5785                // goto/label add nothing.
5786                let s = &metric.cyclomatic;
5787                assert_eq!(s.cyclomatic_sum(), 3);
5788                assert_eq!(s.cyclomatic_modified_sum(), 3);
5789                insta::assert_json_snapshot!(
5790                    metric.cyclomatic,
5791                    @r#"
5792                {
5793                  "sum": 3,
5794                  "value": 1,
5795                  "average": 3.0,
5796                  "min": 1,
5797                  "max": 2,
5798                  "modified": {
5799                    "sum": 3,
5800                    "value": 1,
5801                    "average": 3.0,
5802                    "min": 1,
5803                    "max": 2
5804                  }
5805                }
5806                "#
5807                );
5808            },
5809        );
5810    }
5811
5812    /// Direct accessor coverage: assert the modified-CCN getters return
5813    /// the values we expect from a known fixture, bypassing the JSON
5814    /// serializer.  Modified must never exceed standard for non-degenerate
5815    /// inputs (a switch with at least one arm).
5816    #[test]
5817    fn cyclomatic_modified_accessors() {
5818        check_metrics::<RustParser>(
5819            "fn f(x: u8) -> u8 {
5820                 match x {
5821                     1 => 1,
5822                     2 => 2,
5823                     _ => 0,
5824                 }
5825             }",
5826            "foo.rs",
5827            |metric| {
5828                // standard sum: unit(1) + fn(1 + 2 arms, _ skipped) = 4
5829                // modified sum: unit(1) + fn(1 + 1 MatchExpr)       = 3
5830                let s = &metric.cyclomatic;
5831                assert_eq!(s.cyclomatic_modified_sum(), 3);
5832                assert_eq!(s.cyclomatic_modified_min(), 1);
5833                assert_eq!(s.cyclomatic_modified_max(), 2);
5834                // #512: divisor is the single function space, not the two
5835                // total spaces (unit + fn), so 3 / 1 = 3.0 (was 3 / 2 = 1.5).
5836                assert_eq!(s.cyclomatic_modified_average(), 3.0);
5837                assert!(s.cyclomatic_modified_sum() <= s.cyclomatic_sum());
5838            },
5839        );
5840    }
5841
5842    /// Bare `_ =>` wildcard is not counted (matches C-family `default:`).
5843    #[test]
5844    fn rust_wildcard_only_match() {
5845        check_metrics::<RustParser>(
5846            "fn f(x: u8) -> &'static str {
5847                 match x {
5848                     _ => \"fallback\",
5849                 }
5850             }",
5851            "foo.rs",
5852            |metric| {
5853                // standard: unit(1) + fn(1) + 0 arms (bare wildcard skipped) = 2
5854                // modified: unit(1) + fn(1) + MatchExpr(1) = 3
5855                insta::assert_json_snapshot!(
5856                    metric.cyclomatic,
5857                    @r#"
5858                {
5859                  "sum": 2,
5860                  "value": 1,
5861                  "average": 2.0,
5862                  "min": 1,
5863                  "max": 1,
5864                  "modified": {
5865                    "sum": 3,
5866                    "value": 1,
5867                    "average": 3.0,
5868                    "min": 1,
5869                    "max": 2
5870                  }
5871                }
5872                "#
5873                );
5874            },
5875        );
5876    }
5877
5878    /// Wildcard arm plus explicit arms: only explicit arms count.
5879    #[test]
5880    fn rust_wildcard_plus_explicit_arms() {
5881        check_metrics::<RustParser>(
5882            "fn f(x: u8) -> &'static str {
5883                 match x {
5884                     1 => \"one\",
5885                     2 => \"two\",
5886                     3 => \"three\",
5887                     _ => \"other\",
5888                 }
5889             }",
5890            "foo.rs",
5891            |metric| {
5892                // standard: unit(1) + fn(1) + 3 arms (1,2,3) = 5
5893                // modified: unit(1) + fn(1) + MatchExpr(1) = 3
5894                insta::assert_json_snapshot!(
5895                    metric.cyclomatic,
5896                    @r#"
5897                {
5898                  "sum": 5,
5899                  "value": 1,
5900                  "average": 5.0,
5901                  "min": 1,
5902                  "max": 4,
5903                  "modified": {
5904                    "sum": 3,
5905                    "value": 1,
5906                    "average": 3.0,
5907                    "min": 1,
5908                    "max": 2
5909                  }
5910                }
5911                "#
5912                );
5913            },
5914        );
5915    }
5916
5917    /// `Some(_)` is NOT a bare wildcard — still counts.
5918    #[test]
5919    fn rust_some_wildcard_still_counts() {
5920        check_metrics::<RustParser>(
5921            "fn f(x: Option<u8>) -> u8 {
5922                 match x {
5923                     Some(_) => 1,
5924                     None => 0,
5925                 }
5926             }",
5927            "foo.rs",
5928            |metric| {
5929                // standard: unit(1) + fn(1) + 2 arms (Some(_), None) = 4
5930                // modified: unit(1) + fn(1) + MatchExpr(1) = 3
5931                insta::assert_json_snapshot!(
5932                    metric.cyclomatic,
5933                    @r#"
5934                {
5935                  "sum": 4,
5936                  "value": 1,
5937                  "average": 4.0,
5938                  "min": 1,
5939                  "max": 3,
5940                  "modified": {
5941                    "sum": 3,
5942                    "value": 1,
5943                    "average": 3.0,
5944                    "min": 1,
5945                    "max": 2
5946                  }
5947                }
5948                "#
5949                );
5950            },
5951        );
5952    }
5953
5954    /// Tuple pattern `(_, x)` is NOT a bare wildcard — still counts.
5955    #[test]
5956    fn rust_tuple_wildcard_still_counts() {
5957        check_metrics::<RustParser>(
5958            "fn f(x: (u8, u8)) -> u8 {
5959                 match x {
5960                     (0, y) => y,
5961                     (_, y) => y + 1,
5962                 }
5963             }",
5964            "foo.rs",
5965            |metric| {
5966                // standard: unit(1) + fn(1) + 2 arms = 4
5967                // modified: unit(1) + fn(1) + MatchExpr(1) = 3
5968                insta::assert_json_snapshot!(
5969                    metric.cyclomatic,
5970                    @r#"
5971                {
5972                  "sum": 4,
5973                  "value": 1,
5974                  "average": 4.0,
5975                  "min": 1,
5976                  "max": 3,
5977                  "modified": {
5978                    "sum": 3,
5979                    "value": 1,
5980                    "average": 3.0,
5981                    "min": 1,
5982                    "max": 2
5983                  }
5984                }
5985                "#
5986                );
5987            },
5988        );
5989    }
5990
5991    /// `_ if guard` is NOT a bare wildcard — still counts.
5992    /// The `if` keyword inside the guard also contributes +1 standard/modified.
5993    #[test]
5994    fn rust_guarded_wildcard_still_counts() {
5995        check_metrics::<RustParser>(
5996            "fn f(x: u8) -> &'static str {
5997                 match x {
5998                     1 => \"one\",
5999                     _ if x > 100 => \"big\",
6000                     _ => \"other\",
6001                 }
6002             }",
6003            "foo.rs",
6004            |metric| {
6005                // standard: unit(1) + fn(1 + arm(1) + guarded_arm(1) + if_kw(1)) = 5
6006                // modified: unit(1) + fn(1 + MatchExpr(1) + if_kw(1)) = 4
6007                insta::assert_json_snapshot!(
6008                    metric.cyclomatic,
6009                    @r#"
6010                {
6011                  "sum": 5,
6012                  "value": 1,
6013                  "average": 5.0,
6014                  "min": 1,
6015                  "max": 4,
6016                  "modified": {
6017                    "sum": 4,
6018                    "value": 1,
6019                    "average": 4.0,
6020                    "min": 1,
6021                    "max": 3
6022                  }
6023                }
6024                "#
6025                );
6026            },
6027        );
6028    }
6029
6030    /// Regression #107: empty case…esac has no arms, so standard adds 0 and
6031    /// modified adds 1 (the container).
6032    #[test]
6033    fn bash_case_empty() {
6034        check_metrics::<BashParser>(
6035            "#!/bin/bash
6036f() {
6037    case $1 in
6038    esac
6039}",
6040            "foo.sh",
6041            |metric| {
6042                // standard: unit(1) + fn(1) + 0 arms = 2
6043                // modified: unit(1) + fn(1) + case_stmt(1) = 3
6044                insta::assert_json_snapshot!(
6045                    metric.cyclomatic,
6046                    @r#"
6047                {
6048                  "sum": 2,
6049                  "value": 1,
6050                  "average": 2.0,
6051                  "min": 1,
6052                  "max": 1,
6053                  "modified": {
6054                    "sum": 3,
6055                    "value": 1,
6056                    "average": 3.0,
6057                    "min": 1,
6058                    "max": 2
6059                  }
6060                }
6061                "#
6062                );
6063            },
6064        );
6065    }
6066
6067    /// Regression #107: nested case…esac — each container contributes to
6068    /// modified independently, and each arm contributes to standard.
6069    #[test]
6070    fn bash_nested_case() {
6071        check_metrics::<BashParser>(
6072            "#!/bin/bash
6073f() {
6074    case $1 in
6075        a)
6076            case $2 in
6077                x) echo ax ;;
6078                y) echo ay ;;
6079            esac
6080            ;;
6081        b) echo b ;;
6082    esac
6083}",
6084            "foo.sh",
6085            |metric| {
6086                // standard: unit(1) + fn(1) + outer arms(a,b = 2) + inner arms(x,y = 2) = 6
6087                // modified: unit(1) + fn(1) + 2 case_stmts = 4
6088                insta::assert_json_snapshot!(
6089                    metric.cyclomatic,
6090                    @r#"
6091                {
6092                  "sum": 6,
6093                  "value": 1,
6094                  "average": 6.0,
6095                  "min": 1,
6096                  "max": 5,
6097                  "modified": {
6098                    "sum": 4,
6099                    "value": 1,
6100                    "average": 4.0,
6101                    "min": 1,
6102                    "max": 3
6103                  }
6104                }
6105                "#
6106                );
6107            },
6108        );
6109    }
6110
6111    /// Nested matches with wildcards: only bare `_` skipped at each level.
6112    #[test]
6113    fn rust_nested_match_with_wildcards() {
6114        check_metrics::<RustParser>(
6115            "fn f(x: u8, y: u8) -> &'static str {
6116                 match x {
6117                     1 => match y {
6118                         1 => \"one-one\",
6119                         _ => \"one-other\",
6120                     },
6121                     _ => \"other\",
6122                 }
6123             }",
6124            "foo.rs",
6125            |metric| {
6126                // standard: unit(1) + fn(1) + outer arm 1(+1) + inner arm 1(+1)
6127                //           + outer bare _(0) + inner bare _(0) = 4
6128                // modified: unit(1) + fn(1) + 2 MatchExpr(+2) = 4
6129                insta::assert_json_snapshot!(
6130                    metric.cyclomatic,
6131                    @r#"
6132                {
6133                  "sum": 4,
6134                  "value": 1,
6135                  "average": 4.0,
6136                  "min": 1,
6137                  "max": 3,
6138                  "modified": {
6139                    "sum": 4,
6140                    "value": 1,
6141                    "average": 4.0,
6142                    "min": 1,
6143                    "max": 3
6144                  }
6145                }
6146                "#
6147                );
6148            },
6149        );
6150    }
6151
6152    #[test]
6153    fn ruby_nested_branches() {
6154        // expected: unit(1) + method(1 + `if` + `while`) = 1 + 3 = 4
6155        // standard CCN.
6156        check_metrics::<RubyParser>(
6157            "def foo(a)\n  if a > 0\n    while a > 0\n      a -= 1\n    end\n  end\nend\n",
6158            "foo.rb",
6159            |metric| {
6160                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
6161                insta::assert_json_snapshot!(metric.cyclomatic);
6162            },
6163        );
6164    }
6165
6166    #[test]
6167    fn ruby_case_when_arms() {
6168        // Each `when` arm adds standard CCN; the `case` container is
6169        // counted ONCE in modified CCN.
6170        // expected: standard = unit(1) + method(1 + 3 when) = 5;
6171        // modified = unit(1) + method(1 + 1 case) = 3.
6172        check_metrics::<RubyParser>(
6173            "def foo(x)\n  case x\n  when 1 then 'one'\n  when 2 then 'two'\n  when 3 then 'three'\n  end\nend\n",
6174            "foo.rb",
6175            |metric| {
6176                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
6177                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
6178                insta::assert_json_snapshot!(metric.cyclomatic);
6179            },
6180        );
6181    }
6182
6183    #[test]
6184    fn ruby_case_match_default_only_arm_not_counted() {
6185        // Regression for #977: a `case … in` whose only arm is the bare
6186        // wildcard `in _` (no guard) is a default-only match and must add
6187        // NO standard decision — mirroring Rust's bare-`_` `MatchArm` and
6188        // Python's `case _:` filters. The `case_match` container still
6189        // contributes one modified decision.
6190        // expected per function: standard = 1 (base) + 0 = 1;
6191        // modified = 1 (base) + 1 (case_match) = 2.
6192        check_metrics::<RubyParser>(
6193            "def f(x)\n  case x\n  in _ then :default\n  end\nend\n",
6194            "foo.rb",
6195            |metric| {
6196                assert_eq!(metric.cyclomatic.cyclomatic_max(), 1);
6197                assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 2);
6198            },
6199        );
6200    }
6201
6202    #[test]
6203    fn ruby_case_match_in_arms_and_guard_counted() {
6204        // Regression for #977: a non-wildcard `in 1` arm and a guarded
6205        // wildcard `in _ if x > 0` arm each add one standard decision,
6206        // while the trailing bare `in _` default arm adds none. The
6207        // `case_match` container stays a modified-only decision.
6208        // expected per function: standard = 1 (base) + `in 1` + `in _ if`
6209        // = 3; modified = 1 (base) + 1 (case_match) = 2.
6210        check_metrics::<RubyParser>(
6211            "def f(x)\n  case x\n  in 1 then :one\n  in _ if x > 0 then :positive\n  in _ then :default\n  end\nend\n",
6212            "foo.rb",
6213            |metric| {
6214                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
6215                assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 2);
6216            },
6217        );
6218    }
6219
6220    /// Cross-language parity for default-arm filtering (#977): a
6221    /// match/switch whose single arm is the bare wildcard must score the
6222    /// same per-function cyclomatic across Ruby `case … in`, Rust `match`,
6223    /// and Python `match`. Each language's catch-all arm is its
6224    /// `default:`-equivalent and adds no standard decision, so every
6225    /// function is just its base 1. Per-language snapshot suites pin each
6226    /// history but cannot catch the cross-language disagreement this
6227    /// guards (lesson 11; #106 was exactly a wildcard-counting drift).
6228    #[test]
6229    fn cyclomatic_bare_wildcard_default_arm_cross_language() {
6230        check_metrics::<RubyParser>(
6231            "def f(x)\n  case x\n  in _ then :default\n  end\nend\n",
6232            "foo.rb",
6233            |m| assert_eq!(m.cyclomatic.cyclomatic_max(), 1, "ruby"),
6234        );
6235        check_metrics::<RustParser>(
6236            "fn f(x: i32) -> i32 {\n    match x {\n        _ => 0,\n    }\n}\n",
6237            "foo.rs",
6238            |m| assert_eq!(m.cyclomatic.cyclomatic_max(), 1, "rust"),
6239        );
6240        check_metrics::<PythonParser>(
6241            "def f(x):\n    match x:\n        case _:\n            return 0\n",
6242            "foo.py",
6243            |m| assert_eq!(m.cyclomatic.cyclomatic_max(), 1, "python"),
6244        );
6245    }
6246
6247    #[test]
6248    fn ruby_ternary_conditional() {
6249        // Ruby's `cond ? a : b` parses as `Conditional` and counts as a
6250        // branch in both standard and modified CCN.
6251        // expected: standard = unit(1) + method(1 + 1) = 3.
6252        check_metrics::<RubyParser>(
6253            "def foo(x)\n  x.positive? ? :pos : :nonpos\nend\n",
6254            "foo.rb",
6255            |metric| {
6256                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
6257                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
6258            },
6259        );
6260    }
6261
6262    #[test]
6263    fn ruby_and_or_keywords() {
6264        // Word-form `and` / `or` are distinct grammar kinds from
6265        // `&&` / `||` and must each contribute one decision point.
6266        // expected: standard = unit(1) + method(1 + and + or) = 4.
6267        check_metrics::<RubyParser>(
6268            "def foo(a, b, c)\n  a and b or c\nend\n",
6269            "foo.rb",
6270            |metric| {
6271                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
6272            },
6273        );
6274    }
6275
6276    /// Cross-language parity for cyclomatic: an `if/else if/else` chain
6277    /// of three arms must produce the same per-function (max-space)
6278    /// cyclomatic score across Ruby, Rust, and Java. Per-language
6279    /// snapshot tests pin each language's history but cannot detect
6280    /// drift on the same logical construct — lesson 11
6281    /// (`docs/development/lessons_learned.md`) catalogues real
6282    /// incidents (#106 Rust-vs-C-family wildcard counting; #107 Bash
6283    /// double-counting case containers) that survived per-language
6284    /// suites for years. `cyclomatic_max()` is the function-level
6285    /// cyclomatic and is independent of unit/class space stacking, so
6286    /// the comparison is meaningful across languages with different
6287    /// space hierarchies.
6288    ///
6289    /// Expected per function: 1 (base) + 1 (`if`) + 1 (`else if`) = 3.
6290    /// The `else` arm is unconditional and does not contribute. Each
6291    /// language asserts the literal 3.0 in its own closure so a future
6292    /// drift in any single language fails THIS test (and only this
6293    /// test), making cross-language disagreement visible at a glance.
6294    #[test]
6295    fn cyclomatic_if_elseif_else_chain_cross_language() {
6296        check_metrics::<RubyParser>(
6297            "def classify(x)\n  if x > 0\n    :pos\n  elsif x < 0\n    :neg\n  else\n    :zero\n  end\nend\n",
6298            "foo.rb",
6299            |m| {
6300                assert_eq!(m.cyclomatic.cyclomatic_max(), 3, "ruby");
6301            },
6302        );
6303        check_metrics::<RustParser>(
6304            "fn classify(x: i32) -> &'static str {\n    if x > 0 { \"pos\" } else if x < 0 { \"neg\" } else { \"zero\" }\n}\n",
6305            "foo.rs",
6306            |m| {
6307                assert_eq!(m.cyclomatic.cyclomatic_max(), 3, "rust");
6308            },
6309        );
6310        check_metrics::<JavaParser>(
6311            "class C {\n    String classify(int x) {\n        if (x > 0) return \"pos\";\n        else if (x < 0) return \"neg\";\n        else return \"zero\";\n    }\n}\n",
6312            "Foo.java",
6313            |m| {
6314                assert_eq!(m.cyclomatic.cyclomatic_max(), 3, "java");
6315            },
6316        );
6317    }
6318
6319    /// Parity gate for the `impl_cyclomatic_java_like!` macro (#300):
6320    /// every decision kind shared by Java and Groovy must produce the
6321    /// same per-function cyclomatic score for a common decision-rich
6322    /// method body. Dropping a kind from the macro body (e.g.,
6323    /// removing `For` or `TernaryExpression`) would fail BOTH language
6324    /// assertions; dropping a kind from only one invocation would fail
6325    /// only that language's assertion.
6326    ///
6327    /// The body intentionally exercises every shared kind:
6328    /// `If`, `For`, `While`, `Catch`, `TernaryExpression`, `AMPAMP`,
6329    /// `PIPEPIPE`, plus a `switch` with two `Case` arms (one is the
6330    /// default and contributes nothing under standard CCN). Expected
6331    /// per-function: 1 (base) + if + for + while + catch + ternary +
6332    /// && + || + 2 cases = 10 (standard).
6333    ///
6334    /// Modified CCN is asserted in parallel: the multi-kind arm
6335    /// bumps both counters, and `Switch` (one keyword token per
6336    /// switch construct) replaces the standard CCN's two `Case`
6337    /// arms. Expected modified per-function: 1 (base) + if + for +
6338    /// while + catch + ternary + && + || + switch = 9. Without the
6339    /// modified assertion a mutation that drops
6340    /// `stats.cyclomatic_modified += 1.` from any shared arm (or
6341    /// drops the `Switch` arm entirely) would pass.
6342    #[test]
6343    fn cyclomatic_java_groovy_parity_300() {
6344        const JAVA_SRC: &str = "class C {\n\
6345            int decide(int x, int y, int[] xs) {\n\
6346                int r = 0;\n\
6347                if (x > 0 && y > 0) r = 1;\n\
6348                for (int i = 0; i < 3; i++) r++;\n\
6349                while (x > 0) { x--; r++; }\n\
6350                try { r += xs[0]; } catch (Exception e) { r = -1; }\n\
6351                r = (x > 0 || y < 0) ? r : -r;\n\
6352                switch (x) { case 1: r++; break; case 2: r--; break; default: break; }\n\
6353                return r;\n\
6354            }\n\
6355        }\n";
6356        const GROOVY_SRC: &str = "class C {\n\
6357            int decide(int x, int y, int[] xs) {\n\
6358                int r = 0\n\
6359                if (x > 0 && y > 0) r = 1\n\
6360                for (int i = 0; i < 3; i++) r++\n\
6361                while (x > 0) { x--; r++ }\n\
6362                try { r += xs[0] } catch (Exception e) { r = -1 }\n\
6363                r = (x > 0 || y < 0) ? r : -r\n\
6364                switch (x) { case 1: r++; break; case 2: r--; break; default: break }\n\
6365                return r\n\
6366            }\n\
6367        }\n";
6368        check_metrics::<JavaParser>(JAVA_SRC, "Foo.java", |m| {
6369            assert_eq!(m.cyclomatic.cyclomatic_max(), 10, "java parity");
6370            assert_eq!(
6371                m.cyclomatic.cyclomatic_modified_max(),
6372                9,
6373                "java modified parity"
6374            );
6375        });
6376        check_metrics::<GroovyParser>(GROOVY_SRC, "foo.groovy", |m| {
6377            assert_eq!(m.cyclomatic.cyclomatic_max(), 10, "groovy parity");
6378            assert_eq!(
6379                m.cyclomatic.cyclomatic_modified_max(),
6380                9,
6381                "groovy modified parity"
6382            );
6383        });
6384    }
6385
6386    /// Groovy-only delta in `impl_cyclomatic_java_like!`: the `Assert`
6387    /// extra-kind invocation must keep Groovy's `assert` branching at
6388    /// +1 while Java does not count anything for an identical-looking
6389    /// construct (Java has no `assert`-as-branch token; its `assert`
6390    /// statement is grammar-distinct and not in this macro's arm).
6391    /// Dropping `[Assert]` from the Groovy invocation would fail this
6392    /// test.
6393    #[test]
6394    fn cyclomatic_groovy_assert_arm_300() {
6395        check_metrics::<GroovyParser>("void check(int x) { assert x > 0 }", "foo.groovy", |m| {
6396            // unit(1) + fn(1) + assert(1) = 3
6397            assert_eq!(m.cyclomatic.cyclomatic_sum(), 3, "groovy assert sum");
6398            assert_eq!(m.cyclomatic.cyclomatic_max(), 2, "groovy assert max");
6399            // Assert contributes to BOTH standard and modified CCN, so the
6400            // fn-level modified score is also base(1) + assert(1) = 2.
6401            // Without this assertion, a mutation that dropped
6402            // `stats.cyclomatic_modified += 1.` from the multi-kind arm
6403            // would pass.
6404            assert_eq!(
6405                m.cyclomatic.cyclomatic_modified_max(),
6406                2,
6407                "groovy assert modified max"
6408            );
6409        });
6410    }
6411
6412    /// Regression for issue #246: Groovy's Elvis operator `?:` is a
6413    /// short-circuit nullish operator that introduces a branch — each
6414    /// occurrence in a chain adds +1 to cyclomatic complexity. The
6415    /// dekobon Groovy grammar models Elvis as a distinct
6416    /// `elvis_expression` node with a real `QMARKCOLON` token, so the
6417    /// `impl_cyclomatic_java_like!(GroovyCode, Groovy, [Assert,
6418    /// QMARKCOLON])` invocation picks it up directly.
6419    #[test]
6420    fn cyclomatic_groovy_elvis_chain_246() {
6421        check_metrics::<GroovyParser>(
6422            "def pick(a, b, c) { return a ?: b ?: c }",
6423            "foo.groovy",
6424            |m| {
6425                // unit(1) + fn(1) + two `?:` short-circuits(2) = 4
6426                assert_eq!(m.cyclomatic.cyclomatic_sum(), 4, "groovy elvis sum");
6427                assert_eq!(m.cyclomatic.cyclomatic_max(), 3, "groovy elvis max");
6428                assert_eq!(
6429                    m.cyclomatic.cyclomatic_modified_max(),
6430                    3,
6431                    "groovy elvis modified max"
6432                );
6433            },
6434        );
6435    }
6436
6437    #[test]
6438    fn ruby_rescue_modifier() {
6439        // Postfix `x rescue y` parses as a `RescueModifier` node that
6440        // wraps the recovery clause. Both wrapper and clause fire the
6441        // cyclomatic branch arm; the method body therefore contributes
6442        // +2 to its space.
6443        // expected: standard = unit(1) + method(1 + 1) = 3.
6444        check_metrics::<RubyParser>(
6445            "def foo\n  parse(x) rescue nil\nend\n",
6446            "foo.rb",
6447            |metric| {
6448                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
6449                insta::assert_json_snapshot!(metric.cyclomatic);
6450            },
6451        );
6452    }
6453
6454    #[test]
6455    fn ruby_safe_navigation_cyclomatic() {
6456        // Issue #452: Ruby's safe-navigation `&.` (AMPDOT) is a
6457        // short-circuit decision point per link, mirroring the
6458        // Kotlin/PHP/JS/C# treatment of `?.` (#281). The chain
6459        // `a&.b&.c` adds +2 to both standard and modified CCN.
6460        check_metrics::<RubyParser>("def read(a); a&.b&.c; end\n", "foo.rb", |metric| {
6461            // unit(1) + method(base 1 + &. 1 + &. 1) = sum 4, max 3.
6462            let s = &metric.cyclomatic;
6463            assert_eq!(s.cyclomatic_sum(), 4);
6464            assert_eq!(s.cyclomatic_max(), 3);
6465            assert_eq!(s.cyclomatic_modified_sum(), 4);
6466            assert_eq!(s.cyclomatic_modified_max(), 3);
6467        });
6468    }
6469
6470    /// Nested control flow inside a `when` handler (the iRules floor case,
6471    /// mirroring `rust_1_level_nesting`). unit(1) + handler(base 1 + while 1
6472    /// + if 1 = 3) = sum 4, max 3.
6473    #[test]
6474    fn irules_1_level_nesting() {
6475        check_metrics::<IrulesParser>(
6476            "when HTTP_REQUEST {
6477    while { $x > 0 } {
6478        if { $x > 10 } {
6479            set x [expr { $x - 1 }]
6480        }
6481    }
6482}
6483",
6484            "foo.irule",
6485            |metric| {
6486                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
6487                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
6488                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
6489            },
6490        );
6491    }
6492
6493    /// iRules `switch` is a dedicated node: each non-`default` arm is one
6494    /// standard decision; the whole `switch` is one modified decision.
6495    /// standard: unit(1) + handler(base 1 + 2 arms) = 4; modified:
6496    /// unit(1) + handler(base 1 + switch 1) = 3. The `default` arm is free.
6497    #[test]
6498    fn irules_switch() {
6499        check_metrics::<IrulesParser>(
6500            "when HTTP_REQUEST {
6501    switch [HTTP::host] {
6502        a { pool pool_a }
6503        b { pool pool_b }
6504        default { pool pool_d }
6505    }
6506}
6507",
6508            "foo.irule",
6509            |metric| {
6510                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
6511                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
6512                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
6513                assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 2);
6514            },
6515        );
6516    }
6517
6518    /// The keyword logical operators `and` / `or` are decision points just
6519    /// like `&&` / `||` (iRules-specific — Tcl's grammar has no keyword
6520    /// forms). unit(1) + handler(base 1 + if 1 + and 1 + or 1 = 4) = 5.
6521    /// Guards edge case #3 / the keyword-operator arms in the impl.
6522    #[test]
6523    fn irules_and_or_keywords() {
6524        check_metrics::<IrulesParser>(
6525            "when HTTP_REQUEST {
6526    if { $a and $b or $c } {
6527        log local0. \"hit\"
6528    }
6529}
6530",
6531            "foo.irule",
6532            |metric| {
6533                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
6534                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 5);
6535                assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
6536            },
6537        );
6538    }
6539
6540    /// String comparison operators (`contains`, `eq`, `matches`, …) are
6541    /// operators, NOT branches. Two of them appear here, joined by one `||`;
6542    /// only the `if` and the `||` are decisions: unit(1) + handler(base 1 +
6543    /// if 1 + `||` 1 = 3) = 4. The two string operators add 0. Guards edge
6544    /// case #4: if each string operator were wrongly counted as a branch the
6545    /// sum would be 6, so the divergence (4 vs 6) is unambiguous — it cannot
6546    /// be confused with the `if`/`||` simply being miscounted.
6547    #[test]
6548    fn irules_string_ops_not_branches() {
6549        check_metrics::<IrulesParser>(
6550            "when HTTP_REQUEST {
6551    if { [HTTP::uri] contains \"admin\" || [HTTP::host] eq \"x\" } {
6552        log local0. \"hit\"
6553    }
6554}
6555",
6556            "foo.irule",
6557            |metric| {
6558                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
6559                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
6560                assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
6561            },
6562        );
6563    }
6564
6565    /// A ternary `? :` in an `expr` is one decision; the bare `>` comparison
6566    /// is not. unit(1) + handler(base 1 + ternary 1 = 2) = 3.
6567    #[test]
6568    fn irules_ternary() {
6569        check_metrics::<IrulesParser>(
6570            "when HTTP_REQUEST {
6571    set y [expr { $x > 0 ? 1 : 0 }]
6572}
6573",
6574            "foo.irule",
6575            |metric| {
6576                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
6577                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
6578                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
6579            },
6580        );
6581    }
6582
6583    /// `dict for` iterates and is a loop decision; the non-looping
6584    /// `dict update` / `dict with` are excluded by the impl.
6585    /// unit(1) + handler(base 1 + dict_for 1 = 2) = 3.
6586    #[test]
6587    fn irules_dict_for_loop() {
6588        check_metrics::<IrulesParser>(
6589            "when HTTP_REQUEST {
6590    dict for { k v } $d {
6591        log local0. $k
6592    }
6593}
6594",
6595            "foo.irule",
6596            |metric| {
6597                assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
6598                assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
6599                assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
6600            },
6601        );
6602    }
6603
6604    /// Objective-C floor: an `if` nested inside a `for` inside a
6605    /// `method_definition` held by an `@implementation`. The
6606    /// `@implementation` opens a Class space (+1). Standard CCN =
6607    /// unit(1) + class(1) + method(1) + for(1) + if(1) = 5.
6608    #[test]
6609    fn objc_nested_control() {
6610        check_metrics::<ObjcParser>(
6611            "@implementation Foo
6612- (void)bar:(NSArray *)arr {
6613    for (int i = 0; i < 10; ++i) {
6614        if (i > 5) {
6615            [self use:i];
6616        }
6617    }
6618}
6619@end
6620",
6621            "foo.m",
6622            |metric| {
6623                assert_eq!(metric.cyclomatic.cyclomatic_sum() as u32, 5);
6624                insta::assert_json_snapshot!(metric.cyclomatic, @r#"
6625                {
6626                  "sum": 5,
6627                  "value": 1,
6628                  "average": 5.0,
6629                  "min": 1,
6630                  "max": 3,
6631                  "modified": {
6632                    "sum": 5,
6633                    "value": 1,
6634                    "average": 5.0,
6635                    "min": 1,
6636                    "max": 3
6637                  }
6638                }
6639                "#);
6640            },
6641        );
6642    }
6643
6644    /// Objective-C `@try { } @catch { }`: the `catch_clause` node adds
6645    /// one decision point. Standard CCN = unit(1) + class(1) + method(1)
6646    /// + catch(1) = 4.
6647    #[test]
6648    fn objc_try_catch() {
6649        check_metrics::<ObjcParser>(
6650            "@implementation Foo
6651- (void)bar {
6652    @try {
6653        [self doWork];
6654    } @catch (NSException *e) {
6655        [self log:e];
6656    }
6657}
6658@end
6659",
6660            "foo.m",
6661            |metric| {
6662                assert_eq!(metric.cyclomatic.cyclomatic_sum() as u32, 4);
6663                insta::assert_json_snapshot!(metric.cyclomatic, @r#"
6664                {
6665                  "sum": 4,
6666                  "value": 1,
6667                  "average": 4.0,
6668                  "min": 1,
6669                  "max": 2,
6670                  "modified": {
6671                    "sum": 4,
6672                    "value": 1,
6673                    "average": 4.0,
6674                    "min": 1,
6675                    "max": 2
6676                  }
6677                }
6678                "#);
6679            },
6680        );
6681    }
6682
6683    /// Objective-C fast enumeration `for (id x in arr)` folds into a
6684    /// `for_statement` whose `for` keyword fires once, exactly like a
6685    /// classic `for`. Standard CCN = unit(1) + class(1) + method(1) +
6686    /// for(1) = 4.
6687    #[test]
6688    fn objc_fast_enumeration() {
6689        check_metrics::<ObjcParser>(
6690            "@implementation Foo
6691- (void)bar:(NSArray *)arr {
6692    for (id x in arr) {
6693        [self use:x];
6694    }
6695}
6696@end
6697",
6698            "foo.m",
6699            |metric| {
6700                assert_eq!(metric.cyclomatic.cyclomatic_sum() as u32, 4);
6701                insta::assert_json_snapshot!(metric.cyclomatic, @r#"
6702                {
6703                  "sum": 4,
6704                  "value": 1,
6705                  "average": 4.0,
6706                  "min": 1,
6707                  "max": 2,
6708                  "modified": {
6709                    "sum": 4,
6710                    "value": 1,
6711                    "average": 4.0,
6712                    "min": 1,
6713                    "max": 2
6714                  }
6715                }
6716                "#);
6717            },
6718        );
6719    }
6720}