Skip to main content

big_code_analysis/metrics/
cyclomatic.rs

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