Skip to main content

big_code_analysis/metrics/
cyclomatic.rs

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