Skip to main content

big_code_analysis/metrics/
cyclomatic.rs

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