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