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