Skip to main content

big_code_analysis/metrics/
nexits.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::lang_helpers::perl::perl_bareword_is_key;
24use crate::macros::implement_metric_trait;
25use crate::*;
26
27/// The `NExit` metric.
28///
29/// This metric counts the number of possible exit points
30/// from a function/method.
31#[derive(Debug, Clone, PartialEq)]
32#[non_exhaustive]
33pub struct Stats {
34    exit: usize,
35    exit_sum: usize,
36    total_space_functions: usize,
37    exit_min: usize,
38    exit_max: usize,
39}
40
41impl Default for Stats {
42    fn default() -> Self {
43        Self {
44            exit: 0,
45            exit_sum: 0,
46            total_space_functions: 1,
47            exit_min: usize::MAX,
48            exit_max: 0,
49        }
50    }
51}
52
53impl fmt::Display for Stats {
54    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
55        write!(
56            f,
57            "sum: {}, average: {} min: {}, max: {}",
58            self.nexits_sum(),
59            self.nexits_average(),
60            self.nexits_min(),
61            self.nexits_max()
62        )
63    }
64}
65
66impl Stats {
67    /// Merges a second `NExit` metric into the first one
68    pub fn merge(&mut self, other: &Stats) {
69        self.exit_max = self.exit_max.max(other.exit_max);
70        self.exit_min = self.exit_min.min(other.exit_min);
71        self.exit_sum += other.exit_sum;
72    }
73
74    /// Returns the `NExit` metric value
75    #[must_use]
76    pub fn nexits(&self) -> u64 {
77        self.exit as u64
78    }
79    /// Returns the `NExit` metric sum value
80    #[must_use]
81    pub fn nexits_sum(&self) -> u64 {
82        self.exit_sum as u64
83    }
84    /// Returns the `NExit` metric minimum value.
85    ///
86    /// Collapses the `usize::MAX` sentinel that `Stats::default()` plants
87    /// into `exit_min` to `0`, so a never-observed space
88    /// serializes to a meaningful number rather than `1.8446744e19`.
89    #[must_use]
90    pub fn nexits_min(&self) -> u64 {
91        if self.exit_min == usize::MAX {
92            0
93        } else {
94            self.exit_min as u64
95        }
96    }
97    /// Returns the `NExit` metric maximum value
98    #[must_use]
99    pub fn nexits_max(&self) -> u64 {
100        self.exit_max as u64
101    }
102
103    /// Returns the `NExit` metric average value
104    ///
105    /// This value is computed dividing the `NExit` value
106    /// for the total number of functions/closures in a space.
107    ///
108    /// The per-function divisor (shared with `cyclomatic`/`cognitive`/
109    /// `nargs`, #512) is guarded with `.max(1)` via the shared `average`
110    /// helper, so a space with no counted functions (or one where `Nom`
111    /// was not selected) degrades to `sum / 1` instead of producing
112    /// `inf`/`NaN` (#428).
113    #[must_use]
114    pub fn nexits_average(&self) -> f64 {
115        crate::metrics::average(self.nexits_sum() as f64, self.total_space_functions)
116    }
117    #[inline]
118    pub(crate) fn compute_sum(&mut self) {
119        self.exit_sum += self.exit;
120    }
121    #[inline]
122    pub(crate) fn compute_minmax(&mut self) {
123        self.exit_max = self.exit_max.max(self.exit);
124        self.exit_min = self.exit_min.min(self.exit);
125        self.compute_sum();
126    }
127    pub(crate) fn finalize(&mut self, total_space_functions: usize) {
128        self.total_space_functions = total_space_functions;
129    }
130}
131
132#[doc(hidden)]
133/// Per-language computation of the exit-point count.
134pub(crate) trait Exit
135where
136    Self: Checker,
137{
138    /// Walk `node` and update `stats` with this metric for the language
139    /// implementing the trait.
140    ///
141    /// `ancestors` is `node`'s chain as the metric walk recorded it,
142    /// sibling cache included; Perl reads it to tell an auto-quoted
143    /// `die =>` key from the builtin (#1585).
144    fn compute<'a>(
145        node: &Node<'a>,
146        code: &'a [u8],
147        ancestors: Ancestors<'a, '_>,
148        stats: &mut Stats,
149    );
150}
151
152// Bumps `stats.exit` whenever the current node matches any of the
153// supplied per-language token variants. Mirrors the `js_cognitive!` /
154// `impl_cyclomatic_c_family!` shape used elsewhere in `src/metrics/`.
155macro_rules! impl_exit_match_kinds {
156    ($code:ty, $lang:ident, [$($kind:ident),+ $(,)?]) => {
157        impl Exit for $code {
158            fn compute<'a>(
159                node: &Node<'a>,
160                _code: &'a [u8],
161                _ancestors: Ancestors<'a, '_>,
162                stats: &mut Stats,
163            ) {
164                if matches!(node.kind_id().into(), $($lang::$kind)|+) {
165                    stats.exit += 1;
166                }
167            }
168        }
169    };
170}
171
172// `Python::Yield` is the yield-expression node (kind text "yield"); Python
173// has no dedicated yield-statement variant. Counting it as an exit mirrors
174// `CsharpCode` / `PhpCode`: generator suspension hands control back to the
175// caller, so the function does leave even though it may later resume.
176impl_exit_match_kinds!(PythonCode, Python, [ReturnStatement, RaiseStatement, Yield]);
177// JS-family generators: `yield` / `yield*` parse as `YieldExpression`.
178// Counted for the same reason as Python — see comment above.
179impl_exit_match_kinds!(
180    MozjsCode,
181    Mozjs,
182    [ReturnStatement, ThrowStatement, YieldExpression]
183);
184impl_exit_match_kinds!(
185    JavascriptCode,
186    Javascript,
187    [ReturnStatement, ThrowStatement, YieldExpression]
188);
189impl_exit_match_kinds!(
190    TypescriptCode,
191    Typescript,
192    [ReturnStatement, ThrowStatement, YieldExpression]
193);
194impl_exit_match_kinds!(
195    TsxCode,
196    Tsx,
197    [ReturnStatement, ThrowStatement, YieldExpression]
198);
199// A coroutine's `co_return` is its `return`, and `co_yield` suspends it
200// back to the caller as a Python or JS `yield` does, so both count
201// (#1547).
202impl_exit_match_kinds!(
203    CppCode,
204    Cpp,
205    [
206        ReturnStatement,
207        ThrowStatement,
208        CoReturnStatement,
209        CoYieldStatement
210    ]
211);
212impl_exit_match_kinds!(
213    MozcppCode,
214    Mozcpp,
215    [
216        ReturnStatement,
217        ThrowStatement,
218        CoReturnStatement,
219        CoYieldStatement
220    ]
221);
222// C has no exceptions: `return` is the only exit kind (no `throw`).
223impl_exit_match_kinds!(CCode, C, [ReturnStatement]);
224// Objective-C adds `@throw` on top of C's `return` (the `throw_statement`
225// node), mirroring the C++ exit set.
226impl_exit_match_kinds!(ObjcCode, Objc, [ReturnStatement, ThrowStatement]);
227// Java's `yield` is the Java-14+ switch-expression yield statement
228// (an unambiguous statement node, distinct from a labeled `break`).
229// It hands the switch-expression value back as an explicit exit, so it
230// counts identically to Groovy and C#. Implicit final-expression
231// returns are not counted — only explicit return / throw / yield.
232impl_exit_match_kinds!(
233    JavaCode,
234    Java,
235    [ReturnStatement, ThrowStatement, YieldStatement]
236);
237// Groovy's `yield` is the Java-14+ switch-expression yield, identical
238// to Java's. Implicit-return-from-closure is NOT counted as an exit
239// (consistent with Java) — only explicit return / throw / yield count.
240impl_exit_match_kinds!(
241    GroovyCode,
242    Groovy,
243    [ReturnStatement, ThrowStatement, YieldStatement]
244);
245
246impl Exit for RustCode {
247    fn compute<'a>(
248        node: &Node<'a>,
249        _code: &'a [u8],
250        _ancestors: Ancestors<'a, '_>,
251        stats: &mut Stats,
252    ) {
253        // Count only explicit `return` and `?` (TryExpression). The
254        // implicit final-expression path is NOT an exit — peer-language
255        // impls have the same convention. See #243 for the prior bug
256        // that added a spurious +1 for every function with a return
257        // type.
258        if matches!(
259            node.kind_id().into(),
260            Rust::ReturnExpression | Rust::TryExpression
261        ) {
262            stats.exit += 1;
263        }
264    }
265}
266
267impl Exit for CsharpCode {
268    fn compute<'a>(
269        node: &Node<'a>,
270        _code: &'a [u8],
271        _ancestors: Ancestors<'a, '_>,
272        stats: &mut Stats,
273    ) {
274        if matches!(
275            node.kind_id().into(),
276            Csharp::ReturnStatement
277                | Csharp::YieldStatement
278                | Csharp::ThrowStatement
279                | Csharp::ThrowExpression
280        ) {
281            stats.exit += 1;
282        }
283    }
284}
285
286impl Exit for GoCode {
287    // Go has no dedicated `panic` node: `panic(...)` is the built-in
288    // abrupt-exit call (it unwinds the stack like `throw`/`raise` in the
289    // exception languages), parsed as a `call_expression` whose `function`
290    // field is a bare `identifier` spelling `panic`. Count it as an exit
291    // alongside `return`. Matching the bare identifier (not a
292    // `selector_expression`) means a package-qualified call like
293    // `foo.panic()` — a user function, not the built-in — is not counted,
294    // mirroring how Bash matches the bare builtin command name.
295    fn compute<'a>(
296        node: &Node<'a>,
297        code: &'a [u8],
298        _ancestors: Ancestors<'a, '_>,
299        stats: &mut Stats,
300    ) {
301        if matches!(node.kind_id().into(), Go::ReturnStatement) {
302            stats.exit += 1;
303        } else if node.kind_id() == Go::CallExpression
304            && let Some(function) = node.child_by_field_name("function")
305            && function.kind_id() == Go::Identifier
306            && function.utf8_text(code) == Some("panic")
307        {
308            stats.exit += 1;
309        }
310    }
311}
312
313impl Exit for PerlCode {
314    // Perl's abrupt-exit builtins are `die` (raises an exception that
315    // unwinds to the nearest `eval`) and `exit` (terminates the
316    // process). Neither has a dedicated grammar node: every call form
317    // (`die;`, `die "m"`, `die("m")`, `... or die "m"`) nests a
318    // `call_expression_with_bareword` holding just the callee name, so
319    // matching that one kind counts each occurrence exactly once
320    // whatever wrapper carries the arguments (#1270). Compare the
321    // bareword text for the same reason Go matches `panic` and Lua
322    // matches `error`.
323    //
324    // `CORE::die` / `CORE::exit` are the same builtins spelled through
325    // the `CORE::` namespace — the way code that has overridden `die`
326    // reaches the real one — and keep that qualifier in the bareword
327    // text, so they are matched by name too.
328    //
329    // Deliberate exclusions:
330    // - Any other package-qualified callee keeps its qualifier in the
331    //   bareword text (`Carp::croak`), so it can never equal the four
332    //   spellings matched here.
333    // - `croak` / `confess` are Carp *library* functions, not builtins;
334    //   an unqualified `croak "m"` is left uncounted rather than
335    //   guessing that the module is loaded.
336    // - `$obj->die` parses as a `method_invocation` whose callee is a
337    //   plain `identifier`, never a bareword, so a user method named
338    //   `die` is not counted.
339    //
340    // - An auto-quoted key (`(die => 1)`) is a string, though the
341    //   grammar parses it as a bareword call (#1571 upstream). ABC and
342    //   `find --type call` skip it, and so does this (#1585). The text
343    //   test runs first: the key test reads a sibling, which the walk's
344    //   `SiblingCache` keeps linear under a flat `ERROR`.
345    fn compute<'a>(
346        node: &Node<'a>,
347        code: &'a [u8],
348        ancestors: Ancestors<'a, '_>,
349        stats: &mut Stats,
350    ) {
351        let is_abrupt_exit_builtin = node.kind_id() == Perl::CallExpressionWithBareword
352            && matches!(
353                node.utf8_text(code),
354                Some("die" | "exit" | "CORE::die" | "CORE::exit")
355            )
356            && !perl_bareword_is_key(node, ancestors);
357        if node.kind_id() == Perl::ReturnExpression || is_abrupt_exit_builtin {
358            stats.exit += 1;
359        }
360    }
361}
362
363impl Exit for KotlinCode {
364    fn compute<'a>(
365        node: &Node<'a>,
366        _code: &'a [u8],
367        _ancestors: Ancestors<'a, '_>,
368        stats: &mut Stats,
369    ) {
370        if matches!(
371            node.kind_id().into(),
372            Kotlin::ReturnExpression | Kotlin::ThrowExpression
373        ) {
374            stats.exit += 1;
375        }
376    }
377}
378
379impl Exit for LuaCode {
380    // Lua has no `throw`/`raise` keyword: the abrupt-exit primitives are the
381    // built-in `error(...)` (raises a Lua error that unwinds to the nearest
382    // `pcall`) and `os.exit(...)` (terminates the process). Both parse as a
383    // `function_call` whose `name` field is the callee. `error(...)` is a
384    // bare `identifier`; `os.exit(...)` is a `dot_index_expression` with text
385    // `os.exit`. Count them as exits alongside `return`. Matching the exact
386    // callee text means a user call such as `foo()` or `myError()` is not
387    // counted, mirroring how Bash/Elixir match the bare builtin name.
388    fn compute<'a>(
389        node: &Node<'a>,
390        code: &'a [u8],
391        _ancestors: Ancestors<'a, '_>,
392        stats: &mut Stats,
393    ) {
394        if node.kind_id() == Lua::ReturnStatement {
395            stats.exit += 1;
396        } else if node.kind_id() == Lua::FunctionCall
397            && let Some(name) = node.child_by_field_name("name")
398            && matches!(name.utf8_text(code), Some("error" | "os.exit"))
399        {
400            stats.exit += 1;
401        }
402    }
403}
404
405impl Exit for BashCode {
406    fn compute<'a>(
407        node: &Node<'a>,
408        code: &'a [u8],
409        _ancestors: Ancestors<'a, '_>,
410        stats: &mut Stats,
411    ) {
412        // Bash has no `return_statement` node: `return` and `exit` are
413        // ordinary builtins parsed as `Bash::Command` whose `name` field
414        // points at a `Bash::CommandName`. Identify them by comparing the
415        // command-name text against the literal builtins.
416        if matches!(node.kind_id().into(), Bash::Command)
417            && let Some(name) = node.child_by_field_name("name")
418            && matches!(name.utf8_text(code), Some("return" | "exit"))
419        {
420            stats.exit += 1;
421        }
422    }
423}
424
425impl Exit for TclCode {
426    fn compute<'a>(
427        node: &Node<'a>,
428        code: &'a [u8],
429        _ancestors: Ancestors<'a, '_>,
430        stats: &mut Stats,
431    ) {
432        // Tcl has no return keyword node; `return` is a generic Command whose
433        // name field is a simple_word with text "return" — the same
434        // leading-word resolution the Cognitive / Cyclomatic `switch` and
435        // `for` detectors use, shared rather than restated here.
436        //
437        // `error` (raises an error that unwinds to the nearest `catch`),
438        // the Tcl 8.6 `throw`, and `exit` (terminates the interpreter —
439        // counted for the same reason Perl's, Ruby's and Bash's `exit`
440        // are) are the abrupt-exit builtins and parse to the same generic
441        // Command shape — the vendored grammar has no dedicated rule for
442        // any of them, so the leading word is the only seam (#1270,
443        // lesson 19). Because `tcl_command_name` reads the `name` field,
444        // `error` in argument position (`puts error`) is a `word_list`
445        // child and is not counted.
446        if matches!(
447            crate::lang_helpers::tcl::tcl_command_name(node, code),
448            Some("return" | "error" | "throw" | "exit")
449        ) {
450            stats.exit += 1;
451        }
452    }
453}
454
455impl Exit for IrulesCode {
456    fn compute<'a>(
457        node: &Node<'a>,
458        code: &'a [u8],
459        _ancestors: Ancestors<'a, '_>,
460        stats: &mut Stats,
461    ) {
462        // Like Tcl, iRules has no `return` keyword node — `return` is a
463        // generic Command (it is not among the grammar's `_builtin`
464        // commands). The bare name word can surface as either `simple_word`
465        // or `concat_word` depending on context, so match on the name text
466        // rather than a fixed kind. A multi-value `return $a $b` still has a
467        // single `name` field and is counted once.
468        //
469        // `error` is the Tcl abrupt-exit builtin and reaches iRules
470        // unchanged; re-derived against the iRules grammar rather than
471        // assumed from Tcl's, it parses to the same `command` +
472        // name-word shape (#1270). Tcl 8.6's `throw` is *not* matched
473        // here: TMOS iRules runs a Tcl 8.4-derived interpreter that has
474        // no `throw` builtin, so the word could only ever name a user
475        // proc. iRules flow commands (`event disable`, `TCP::close`,
476        // `reject`, `drop`) remain deliberately uncounted as exits in
477        // v1.
478        // Normalised for the same reason `tcl_command_name` is: `::return`
479        // is `return` through the global namespace. This walker resolves
480        // the name itself rather than through that helper, so the strip
481        // has to be repeated here (#1381 review).
482        if node.kind_id() == Irules::Command
483            && let Some(name) = node.child_by_field_name("name")
484            && matches!(
485                name.utf8_text(code)
486                    .map(crate::lang_helpers::strip_global_qualifier),
487                Some("return" | "error")
488            )
489        {
490            stats.exit += 1;
491        }
492    }
493}
494
495impl Exit for PhpCode {
496    // tree-sitter-php 0.25.1's `exit_statement` rule covers `exit` only
497    // (with or without parentheses); `die(...)` is grammar-classified as
498    // a `function_call_expression` and therefore is NOT counted here.
499    // Detecting `die` would require inspecting call-expression callee
500    // text — brittle and likely to false-match user-defined `die`
501    // functions. Modern PHP idiom favors `throw new Exception()` over
502    // `die`, so leaving this asymmetric is acceptable.
503    fn compute<'a>(
504        node: &Node<'a>,
505        _code: &'a [u8],
506        _ancestors: Ancestors<'a, '_>,
507        stats: &mut Stats,
508    ) {
509        if matches!(
510            node.kind_id().into(),
511            Php::ReturnStatement | Php::YieldExpression | Php::ThrowExpression | Php::ExitStatement
512        ) {
513            stats.exit += 1;
514        }
515    }
516}
517
518// Real defaults — no functions to return from. Audited in #188.
519implement_metric_trait!(Exit, PreprocCode, CcommentCode);
520
521impl Exit for RubyCode {
522    // Ruby's `return` is the only dedicated grammar node for an
523    // intra-function exit; `yield` passes control to the block but does
524    // not exit the enclosing method. tree-sitter-ruby exposes the
525    // `return_statement` rule under two aliased visible kinds
526    // (`Return`, `Return2`); the `Return3` token is the bare `return`
527    // keyword inside those nodes and is not counted on its own.
528    //
529    // `raise` and `exit` are ordinary method calls with no grammar node
530    // of their own — which is the shape the Go / Lua / Elixir impls
531    // above already match by callee text, not a reason to leave them
532    // uncounted (#1270). Both parse as a `call` whose `method` field is
533    // a bare `identifier`; `Checker::is_call` carries the four visible
534    // `call` aliases, so the arm cannot miss one by position.
535    //
536    // `exit!` (`Kernel#exit!`, the immediate process exit) is the third
537    // spelling, and the explicit `Kernel.raise` / `Kernel.exit` receiver
538    // *is* the builtin — it is reached for precisely when a DSL shadows
539    // the bare name — so a `Kernel` constant receiver is admitted.
540    //
541    // Deliberate exclusions:
542    // - A call with any other `receiver` (`obj.raise`, `self.exit`) is a
543    //   user method, never the Kernel builtin — the same bare-callee
544    //   gate Go uses to keep `foo.panic()` out.
545    // - A *bare* `raise` / `exit` with no arguments parses as a plain
546    //   `identifier`, indistinguishable from reading a local variable
547    //   of that name, so the argument-less re-raise idiom inside
548    //   `rescue` goes uncounted rather than blanket-matching every
549    //   identifier. A bare `exit!` is different: the `!` cannot name a
550    //   local, so the grammar emits a `call` and it is counted.
551    // - `fail` (a Kernel alias of `raise`), `abort`, and `throw`
552    //   (catch/throw non-local flow) are not matched: each is a common
553    //   user or test-DSL method name, and the counted set stays the two
554    //   primitives the cross-language exit table names.
555    //
556    // The callee is inspected before the receiver: every call has a
557    // `method`, and the text test rejects nearly all of them, so the
558    // receiver lookup runs only for the handful that spell a builtin.
559    fn compute<'a>(
560        node: &Node<'a>,
561        code: &'a [u8],
562        _ancestors: Ancestors<'a, '_>,
563        stats: &mut Stats,
564    ) {
565        if matches!(node.kind_id().into(), Ruby::Return | Ruby::Return2) {
566            stats.exit += 1;
567        } else if Self::is_call(node)
568            && let Some(method) = node.child_by_field_name("method")
569            && method.kind_id() == Ruby::Identifier
570            && matches!(method.utf8_text(code), Some("raise" | "exit" | "exit!"))
571            && node.child_by_field_name("receiver").is_none_or(|receiver| {
572                receiver.kind_id() == Ruby::Constant && receiver.utf8_text(code) == Some("Kernel")
573            })
574        {
575            stats.exit += 1;
576        }
577    }
578}
579
580impl Exit for ElixirCode {
581    // Elixir has no `return` statement: the last expression in a function
582    // body is the return value. Early-exit happens through `throw`,
583    // `raise`, `reraise`, or `exit`, all of which surface as `Call`
584    // nodes whose target is an `Identifier` whose text spells the
585    // keyword. Mirrors the Bash/Tcl pattern of comparing target text.
586    fn compute<'a>(
587        node: &Node<'a>,
588        code: &'a [u8],
589        _ancestors: Ancestors<'a, '_>,
590        stats: &mut Stats,
591    ) {
592        if node.kind_id() == Elixir::Call
593            && let Some(target) = node.child_by_field_name("target")
594            && target.kind_id() == Elixir::Identifier
595            && matches!(
596                target.utf8_text(code),
597                Some("throw" | "raise" | "reraise" | "exit")
598            )
599        {
600            stats.exit += 1;
601        }
602    }
603}
604
605#[cfg(test)]
606#[allow(
607    clippy::float_cmp,
608    clippy::cast_precision_loss,
609    clippy::cast_possible_truncation,
610    clippy::cast_sign_loss,
611    clippy::similar_names,
612    clippy::doc_markdown,
613    clippy::needless_raw_string_hashes,
614    clippy::too_many_lines
615)]
616mod tests {
617    use crate::test_support::{
618        check_func_space_only_shim, check_metrics_only_shim, child_space, function_space,
619        metrics_verbatim, parse_named,
620    };
621
622    use super::*;
623
624    // Nexits pulls Nom for its per-function average divisor, which is
625    // also what this module's one `metric.nom.functions_sum()`
626    // assertion reads.
627    check_metrics_only_shim!(check_metrics, Nexits);
628    check_func_space_only_shim!(check_func_space, Nexits);
629
630    /// A `Stats::default()` that never sees an
631    /// observation must not leak the `usize::MAX` sentinel for
632    /// `exit_min`. The getter collapses the sentinel to `0.0` so
633    /// JSON never emits `1.8446744e19`.
634    #[test]
635    fn exit_empty_file_min_is_zero() {
636        let stats = Stats::default();
637        assert_eq!(stats.nexits_min(), 0);
638    }
639
640    #[cfg(feature = "python")]
641    #[test]
642    fn python_no_exit() {
643        check_metrics::<PythonParser>("a = 42", "foo.py", |metric| {
644            // 0 functions
645            insta::assert_json_snapshot!(
646                metric.nexits,
647                @r#"
648            {
649              "sum": 0,
650              "average": 0.0,
651              "min": 0,
652              "max": 0
653            }
654            "#
655            );
656        });
657    }
658
659    #[cfg(feature = "rust")]
660    #[test]
661    fn rust_no_exit() {
662        check_metrics::<RustParser>("let a = 42;", "foo.rs", |metric| {
663            // 0 functions
664            insta::assert_json_snapshot!(
665                metric.nexits,
666                @r#"
667            {
668              "sum": 0,
669              "average": 0.0,
670              "min": 0,
671              "max": 0
672            }
673            "#
674            );
675        });
676    }
677
678    #[cfg(feature = "rust")]
679    #[test]
680    fn rust_question_mark() {
681        check_metrics::<RustParser>("let _ = a? + b? + c?;", "foo.rs", |metric| {
682            // 0 functions
683            insta::assert_json_snapshot!(
684                metric.nexits,
685                @r#"
686            {
687              "sum": 3,
688              "average": 3.0,
689              "min": 3,
690              "max": 3
691            }
692            "#
693            );
694        });
695    }
696
697    // Regression for #243: `Exit for RustCode` used to add 1 whenever
698    // a function_item with an explicit `-> T` was visited. Because the
699    // spaces traversal pushes a new State *before* Exit::compute runs
700    // for that function_item, every Rust function with an explicit
701    // return type was getting one extra exit on top of its real
702    // `return` / `?` exits. The fix drops the spurious clause; this
703    // test pins exit == 1 for a function with one explicit return.
704    #[cfg(feature = "rust")]
705    #[test]
706    fn rust_explicit_return_with_return_type() {
707        check_metrics::<RustParser>("fn foo() -> i32 { return 1; }", "foo.rs", |metric| {
708            // 1 explicit return / 1 space
709            insta::assert_json_snapshot!(
710                metric.nexits,
711                @r#"
712            {
713              "sum": 1,
714              "average": 1.0,
715              "min": 0,
716              "max": 1
717            }
718            "#
719            );
720        });
721    }
722
723    // Regression for #243: an implicit final-expression return must
724    // NOT count as an exit — matching every other language's
725    // convention (Java, C++, Go, etc. don't count implicit returns).
726    #[cfg(feature = "rust")]
727    #[test]
728    fn rust_implicit_return_not_counted() {
729        check_metrics::<RustParser>("fn foo() -> i32 { 0 }", "foo.rs", |metric| {
730            // 0 explicit exits / 1 space
731            insta::assert_json_snapshot!(
732                metric.nexits,
733                @r#"
734            {
735              "sum": 0,
736              "average": 0.0,
737              "min": 0,
738              "max": 0
739            }
740            "#
741            );
742        });
743    }
744
745    // Regression for #243: a function with both an explicit return on
746    // one branch and an implicit final expression should count only
747    // the explicit return.
748    #[cfg(feature = "rust")]
749    #[test]
750    fn rust_mixed_explicit_and_implicit_return() {
751        check_metrics::<RustParser>(
752            "fn foo(x: bool) -> i32 { if x { return 1; } 0 }",
753            "foo.rs",
754            |metric| {
755                // 1 explicit return; the implicit `0` is not an exit
756                insta::assert_json_snapshot!(
757                    metric.nexits,
758                    @r#"
759                {
760                  "sum": 1,
761                  "average": 1.0,
762                  "min": 0,
763                  "max": 1
764                }
765                "#
766                );
767            },
768        );
769    }
770
771    // Regression for #243: `?` inside a function body is the only
772    // implicit-exit form that does count, and the function having an
773    // explicit `Result` return type must not double it.
774    #[cfg(feature = "rust")]
775    #[test]
776    fn rust_question_mark_in_function() {
777        check_metrics::<RustParser>(
778            "fn foo() -> Result<i32, ()> { Ok(do_thing()?) }",
779            "foo.rs",
780            |metric| {
781                // 1 `?` operator, no explicit `return`
782                insta::assert_json_snapshot!(
783                    metric.nexits,
784                    @r#"
785                {
786                  "sum": 1,
787                  "average": 1.0,
788                  "min": 0,
789                  "max": 1
790                }
791                "#
792                );
793            },
794        );
795    }
796
797    // Regression for #243: a unit-returning function with no
798    // explicit `return` or `?` must report 0 exits.
799    #[cfg(feature = "rust")]
800    #[test]
801    fn rust_unit_return_no_exit() {
802        check_metrics::<RustParser>("fn foo() { let _x = 1; }", "foo.rs", |metric| {
803            // 0 exits / 1 space
804            insta::assert_json_snapshot!(
805                metric.nexits,
806                @r#"
807            {
808              "sum": 0,
809              "average": 0.0,
810              "min": 0,
811              "max": 0
812            }
813            "#
814            );
815        });
816    }
817
818    #[cfg(feature = "c")]
819    #[test]
820    fn c_no_exit() {
821        check_metrics::<CParser>("int a = 42;", "foo.c", |metric| {
822            // 0 functions
823            insta::assert_json_snapshot!(
824                metric.nexits,
825                @r#"
826            {
827              "sum": 0,
828              "average": 0.0,
829              "min": 0,
830              "max": 0
831            }
832            "#
833            );
834        });
835    }
836
837    /// Multiple `return` statements across `if` / `else` branches.  Every
838    /// `Cpp::ReturnStatement` adds +1 — there is no early-out collapse.
839    #[cfg(feature = "c")]
840    #[test]
841    fn c_multiple_returns_in_branches() {
842        check_metrics::<CParser>(
843            "int f(int x) {
844                 if (x < 0) {
845                     return -1;
846                 } else if (x == 0) {
847                     return 0;
848                 } else {
849                     return 1;
850                 }
851             }",
852            "foo.c",
853            |metric| {
854                // 1 function, 3 returns
855                assert_eq!(metric.nexits.nexits_sum(), 3);
856                assert_eq!(metric.nexits.nexits_max(), 3);
857                insta::assert_json_snapshot!(
858                    metric.nexits,
859                    @r#"
860                {
861                  "sum": 3,
862                  "average": 3.0,
863                  "min": 0,
864                  "max": 3
865                }
866                "#
867                );
868            },
869        );
870    }
871
872    /// The raison d'être of `LANG::C` (#721): C code that uses C++
873    /// keywords (`new`, `class`, `delete`) as plain identifiers parses
874    /// cleanly through `tree-sitter-c`, where the C++ grammar
875    /// ERROR-cascades. The load-bearing assertion is `!root.has_error()`:
876    /// the C++ grammar errors on this input yet *still* recovers a
877    /// function node and two `return`s, so a metric-count assertion alone
878    /// does not distinguish the two grammars — only the error-free parse
879    /// does. C has no `throw`, so `return` is the sole exit kind.
880    #[cfg(feature = "c")]
881    #[test]
882    fn c_keyword_identifiers_parse_and_returns_count() {
883        use std::path::PathBuf;
884
885        let source = "int process(int new, int class) {
886                 int delete = new + class;
887                 if (delete > 0) {
888                     return delete;
889                 }
890                 return 0;
891             }";
892        let parser = CParser::new(source.as_bytes().to_vec(), &PathBuf::from("foo.c"), None);
893        assert!(
894            !parser.root().has_error(),
895            "C grammar must parse C++-keyword identifiers without an error cascade"
896        );
897
898        check_metrics::<CParser>(source, "foo.c", |metric| {
899            assert_eq!(metric.nom.functions_sum(), 1);
900            assert_eq!(metric.nexits.nexits_sum(), 2);
901        });
902    }
903
904    /// `return` statements inside `try` and `catch` blocks both count;
905    /// the impl matches `Cpp::ReturnStatement` regardless of enclosing
906    /// scope.  C++-only: bare C has no `try`/`catch`.
907    #[cfg(feature = "cpp")]
908    #[test]
909    fn cpp_return_in_try_catch() {
910        check_metrics::<CppParser>(
911            "int f(int x) {
912                 try {
913                     if (x == 0) {
914                         return 1;
915                     }
916                     return 2;
917                 } catch (...) {
918                     return -1;
919                 }
920             }",
921            "foo.cpp",
922            |metric| {
923                // 1 function, 3 returns (2 in try, 1 in catch); no
924                // `throw` here, so the return-only path stays at 3.
925                assert_eq!(metric.nexits.nexits_sum(), 3);
926                assert_eq!(metric.nexits.nexits_max(), 3);
927                insta::assert_json_snapshot!(
928                    metric.nexits,
929                    @r#"
930                {
931                  "sum": 3,
932                  "average": 3.0,
933                  "min": 0,
934                  "max": 3
935                }
936                "#
937                );
938            },
939        );
940    }
941
942    /// Early `return` inside a loop body is counted separately from the
943    /// trailing return — every reachable `return` is an exit.
944    #[cfg(feature = "c")]
945    #[test]
946    fn c_early_return_in_loop() {
947        check_metrics::<CParser>(
948            "int find(int* a, int n, int target) {
949                 for (int i = 0; i < n; ++i) {
950                     if (a[i] == target) {
951                         return i;
952                     }
953                 }
954                 return -1;
955             }",
956            "foo.c",
957            |metric| {
958                // 1 function, 2 returns
959                assert_eq!(metric.nexits.nexits_sum(), 2);
960                assert_eq!(metric.nexits.nexits_max(), 2);
961                insta::assert_json_snapshot!(
962                    metric.nexits,
963                    @r#"
964                {
965                  "sum": 2,
966                  "average": 2.0,
967                  "min": 0,
968                  "max": 2
969                }
970                "#
971                );
972            },
973        );
974    }
975
976    /// `void` function with no explicit `return` — exit count is 0.
977    /// The implicit fall-through return is intentionally not modelled.
978    #[cfg(feature = "c")]
979    #[test]
980    fn c_void_no_explicit_return() {
981        check_metrics::<CParser>(
982            "void greet(const char* who) {
983                 printf(\"hi %s\\n\", who);
984             }",
985            "foo.c",
986            |metric| {
987                // 1 function with zero ReturnStatement nodes.
988                assert_eq!(metric.nexits.nexits_sum(), 0);
989                assert_eq!(metric.nexits.nexits_max(), 0);
990                insta::assert_json_snapshot!(
991                    metric.nexits,
992                    @r#"
993                {
994                  "sum": 0,
995                  "average": 0.0,
996                  "min": 0,
997                  "max": 0
998                }
999                "#
1000                );
1001            },
1002        );
1003    }
1004
1005    #[cfg(feature = "javascript")]
1006    #[test]
1007    fn javascript_no_exit() {
1008        check_metrics::<JavascriptParser>("var a = 42;", "foo.js", |metric| {
1009            // 0 functions
1010            insta::assert_json_snapshot!(
1011                metric.nexits,
1012                @r#"
1013            {
1014              "sum": 0,
1015              "average": 0.0,
1016              "min": 0,
1017              "max": 0
1018            }
1019            "#
1020            );
1021        });
1022    }
1023
1024    #[cfg(feature = "javascript")]
1025    #[test]
1026    fn javascript_simple_function() {
1027        check_metrics::<JavascriptParser>(
1028            "function f(a, b) {
1029                 if (a) {
1030                     return a;
1031                 }
1032                 return b;
1033             }",
1034            "foo.js",
1035            |metric| {
1036                // 1 function with 2 return statements
1037                insta::assert_json_snapshot!(
1038                    metric.nexits,
1039                    @r#"
1040                {
1041                  "sum": 2,
1042                  "average": 2.0,
1043                  "min": 0,
1044                  "max": 2
1045                }
1046                "#
1047                );
1048            },
1049        );
1050    }
1051
1052    #[cfg(feature = "javascript")]
1053    #[test]
1054    fn javascript_nested_functions() {
1055        check_metrics::<JavascriptParser>(
1056            "function outer() {
1057                 function inner() {
1058                     return 1;
1059                 }
1060                 return inner();
1061             }",
1062            "foo.js",
1063            |metric| {
1064                // 2 functions, each with 1 return
1065                insta::assert_json_snapshot!(
1066                    metric.nexits,
1067                    @r#"
1068                {
1069                  "sum": 2,
1070                  "average": 1.0,
1071                  "min": 0,
1072                  "max": 1
1073                }
1074                "#
1075                );
1076            },
1077        );
1078    }
1079
1080    #[cfg(feature = "python")]
1081    #[test]
1082    fn python_simple_function() {
1083        check_metrics::<PythonParser>(
1084            "def f(a, b):
1085                 if a:
1086                     return a",
1087            "foo.py",
1088            |metric| {
1089                // 1 function
1090                insta::assert_json_snapshot!(
1091                    metric.nexits,
1092                    @r#"
1093                {
1094                  "sum": 1,
1095                  "average": 1.0,
1096                  "min": 0,
1097                  "max": 1
1098                }
1099                "#
1100                );
1101            },
1102        );
1103    }
1104
1105    #[cfg(feature = "python")]
1106    #[test]
1107    fn python_more_functions() {
1108        check_metrics::<PythonParser>(
1109            "def f(a, b):
1110                 if a:
1111                     return a
1112            def f(a, b):
1113                 if b:
1114                     return b",
1115            "foo.py",
1116            |metric| {
1117                // 2 functions
1118                insta::assert_json_snapshot!(
1119                    metric.nexits,
1120                    @r#"
1121                {
1122                  "sum": 2,
1123                  "average": 1.0,
1124                  "min": 0,
1125                  "max": 1
1126                }
1127                "#
1128                );
1129            },
1130        );
1131    }
1132
1133    #[cfg(feature = "python")]
1134    #[test]
1135    fn python_nested_functions() {
1136        check_metrics::<PythonParser>(
1137            "def f(a, b):
1138                 def foo(a):
1139                     if a:
1140                         return 1
1141                 bar = lambda a: lambda b: b or True or True
1142                 return bar(foo(a))(a)",
1143            "foo.py",
1144            |metric| {
1145                // 2 functions + 2 lambdas = 4
1146                insta::assert_json_snapshot!(
1147                    metric.nexits,
1148                    @r#"
1149                {
1150                  "sum": 2,
1151                  "average": 0.5,
1152                  "min": 0,
1153                  "max": 1
1154                }
1155                "#
1156                );
1157            },
1158        );
1159    }
1160
1161    #[cfg(feature = "java")]
1162    #[test]
1163    fn java_no_exit() {
1164        check_metrics::<JavaParser>("int a = 42;", "foo.java", |metric| {
1165            // 0 functions
1166            insta::assert_json_snapshot!(
1167                metric.nexits,
1168                @r#"
1169            {
1170              "sum": 0,
1171              "average": 0.0,
1172              "min": 0,
1173              "max": 0
1174            }
1175            "#
1176            );
1177        });
1178    }
1179
1180    #[cfg(feature = "java")]
1181    #[test]
1182    fn java_simple_function() {
1183        check_metrics::<JavaParser>(
1184            "class A {
1185              public int sum(int x, int y) {
1186                return x + y;
1187              }
1188            }",
1189            "foo.java",
1190            |metric| {
1191                // 1 exit / 1 space
1192                insta::assert_json_snapshot!(
1193                    metric.nexits,
1194                    @r#"
1195                {
1196                  "sum": 1,
1197                  "average": 1.0,
1198                  "min": 0,
1199                  "max": 1
1200                }
1201                "#
1202                );
1203            },
1204        );
1205    }
1206
1207    #[cfg(feature = "go")]
1208    #[test]
1209    fn go_no_return() {
1210        check_metrics::<GoParser>(
1211            "package main
1212            func f() {
1213                x := 1
1214                _ = x
1215            }",
1216            "foo.go",
1217            |metric| {
1218                // No return_statement → exit_sum = 0.
1219                insta::assert_json_snapshot!(
1220                    metric.nexits,
1221                    @r#"
1222                {
1223                  "sum": 0,
1224                  "average": 0.0,
1225                  "min": 0,
1226                  "max": 0
1227                }
1228                "#
1229                );
1230            },
1231        );
1232    }
1233
1234    #[cfg(feature = "go")]
1235    #[test]
1236    fn go_single_return() {
1237        check_metrics::<GoParser>(
1238            "package main
1239            func f() int {
1240                return 1
1241            }",
1242            "foo.go",
1243            |metric| {
1244                insta::assert_json_snapshot!(
1245                    metric.nexits,
1246                    @r#"
1247                {
1248                  "sum": 1,
1249                  "average": 1.0,
1250                  "min": 0,
1251                  "max": 1
1252                }
1253                "#
1254                );
1255            },
1256        );
1257    }
1258
1259    #[cfg(feature = "go")]
1260    #[test]
1261    fn go_multiple_returns() {
1262        check_metrics::<GoParser>(
1263            "package main
1264            func f(x int) int {
1265                if x > 0 {
1266                    return 1
1267                }
1268                if x < 0 {
1269                    return -1
1270                }
1271                return 0
1272            }",
1273            "foo.go",
1274            |metric| {
1275                // 3 distinct return_statements across branches.
1276                insta::assert_json_snapshot!(
1277                    metric.nexits,
1278                    @r#"
1279                {
1280                  "sum": 3,
1281                  "average": 3.0,
1282                  "min": 0,
1283                  "max": 3
1284                }
1285                "#
1286                );
1287            },
1288        );
1289    }
1290
1291    #[cfg(feature = "go")]
1292    #[test]
1293    fn go_naked_return() {
1294        check_metrics::<GoParser>(
1295            "package main
1296            func f() (x int) {
1297                x = 1
1298                return
1299            }",
1300            "foo.go",
1301            |metric| {
1302                // Bare `return` with named results is still a return_statement.
1303                insta::assert_json_snapshot!(
1304                    metric.nexits,
1305                    @r#"
1306                {
1307                  "sum": 1,
1308                  "average": 1.0,
1309                  "min": 0,
1310                  "max": 1
1311                }
1312                "#
1313                );
1314            },
1315        );
1316    }
1317
1318    #[cfg(feature = "go")]
1319    #[test]
1320    fn go_multivalue_return() {
1321        check_metrics::<GoParser>(
1322            "package main
1323            func f() (int, error) {
1324                return 0, nil
1325            }",
1326            "foo.go",
1327            |metric| {
1328                // `return a, b` is one return_statement (Go has no comma operator).
1329                insta::assert_json_snapshot!(
1330                    metric.nexits,
1331                    @r#"
1332                {
1333                  "sum": 1,
1334                  "average": 1.0,
1335                  "min": 0,
1336                  "max": 1
1337                }
1338                "#
1339                );
1340            },
1341        );
1342    }
1343
1344    #[cfg(feature = "go")]
1345    #[test]
1346    fn go_panic_counts_as_exit() {
1347        check_metrics::<GoParser>(
1348            "package main
1349            func f() {
1350                panic(\"boom\")
1351            }",
1352            "foo.go",
1353            |metric| {
1354                // panic(...) is the built-in abrupt-exit call, counted like
1355                // throw/raise — one exit even though there is no `return`.
1356                insta::assert_json_snapshot!(
1357                    metric.nexits,
1358                    @r#"
1359                {
1360                  "sum": 1,
1361                  "average": 1.0,
1362                  "min": 0,
1363                  "max": 1
1364                }
1365                "#
1366                );
1367            },
1368        );
1369    }
1370
1371    #[cfg(feature = "go")]
1372    #[test]
1373    fn go_panic_and_return_both_count() {
1374        check_metrics::<GoParser>(
1375            "package main
1376            func f(x int) int {
1377                if x < 0 {
1378                    panic(\"negative\")
1379                }
1380                return x
1381            }",
1382            "foo.go",
1383            |metric| {
1384                // panic(...) + return are both abrupt exits → 2.
1385                insta::assert_json_snapshot!(
1386                    metric.nexits,
1387                    @r#"
1388                {
1389                  "sum": 2,
1390                  "average": 2.0,
1391                  "min": 0,
1392                  "max": 2
1393                }
1394                "#
1395                );
1396            },
1397        );
1398    }
1399
1400    #[cfg(feature = "go")]
1401    #[test]
1402    fn go_package_qualified_panic_is_not_exit() {
1403        check_metrics::<GoParser>(
1404            "package main
1405            func f() {
1406                foo.panic()
1407            }",
1408            "foo.go",
1409            |metric| {
1410                // `foo.panic()` is a user method on package `foo`, not the
1411                // built-in `panic` — its callee is a selector_expression, not
1412                // a bare identifier, so it must not be counted.
1413                insta::assert_json_snapshot!(
1414                    metric.nexits,
1415                    @r#"
1416                {
1417                  "sum": 0,
1418                  "average": 0.0,
1419                  "min": 0,
1420                  "max": 0
1421                }
1422                "#
1423                );
1424            },
1425        );
1426    }
1427
1428    #[cfg(feature = "java")]
1429    #[test]
1430    fn java_split_function() {
1431        check_metrics::<JavaParser>(
1432            "class A {
1433              public int multiply(int x, int y) {
1434                if(x == 0 || y == 0){
1435                    return 0;
1436                }
1437                return x * y;
1438              }
1439            }",
1440            "foo.java",
1441            |metric| {
1442                // 2 exit / space 1
1443                insta::assert_json_snapshot!(
1444                    metric.nexits,
1445                    @r#"
1446                {
1447                  "sum": 2,
1448                  "average": 2.0,
1449                  "min": 0,
1450                  "max": 2
1451                }
1452                "#
1453                );
1454            },
1455        );
1456    }
1457
1458    #[cfg(feature = "csharp")]
1459    #[test]
1460    fn csharp_no_exit() {
1461        check_metrics::<CsharpParser>("int a = 42;", "foo.cs", |metric| {
1462            insta::assert_json_snapshot!(
1463                metric.nexits,
1464                @r#"
1465            {
1466              "sum": 0,
1467              "average": 0.0,
1468              "min": 0,
1469              "max": 0
1470            }
1471            "#
1472            );
1473        });
1474    }
1475
1476    #[cfg(feature = "csharp")]
1477    #[test]
1478    fn csharp_simple_function() {
1479        check_metrics::<CsharpParser>(
1480            "class A {
1481              public int Sum(int x, int y) {
1482                return x + y;
1483              }
1484            }",
1485            "foo.cs",
1486            |metric| {
1487                insta::assert_json_snapshot!(
1488                    metric.nexits,
1489                    @r#"
1490                {
1491                  "sum": 1,
1492                  "average": 1.0,
1493                  "min": 0,
1494                  "max": 1
1495                }
1496                "#
1497                );
1498            },
1499        );
1500    }
1501
1502    #[cfg(feature = "csharp")]
1503    #[test]
1504    fn csharp_split_function() {
1505        check_metrics::<CsharpParser>(
1506            "class A {
1507              public int Multiply(int x, int y) {
1508                if (x == 0 || y == 0) {
1509                    return 0;
1510                }
1511                return x * y;
1512              }
1513            }",
1514            "foo.cs",
1515            |metric| {
1516                insta::assert_json_snapshot!(
1517                    metric.nexits,
1518                    @r#"
1519                {
1520                  "sum": 2,
1521                  "average": 2.0,
1522                  "min": 0,
1523                  "max": 2
1524                }
1525                "#
1526                );
1527            },
1528        );
1529    }
1530
1531    #[cfg(feature = "csharp")]
1532    #[test]
1533    fn csharp_yield_and_throw() {
1534        check_metrics::<CsharpParser>(
1535            "class A {
1536              public IEnumerable<int> Gen() {
1537                yield return 1;
1538                yield break;
1539              }
1540              public int Bad(int x) {
1541                if (x < 0) throw new System.Exception();
1542                return x;
1543              }
1544            }",
1545            "foo.cs",
1546            |metric| {
1547                // 2 yields + 1 throw + 1 return = 4 across two methods.
1548                insta::assert_json_snapshot!(
1549                    metric.nexits,
1550                    @r#"
1551                {
1552                  "sum": 4,
1553                  "average": 2.0,
1554                  "min": 0,
1555                  "max": 2
1556                }
1557                "#
1558                );
1559            },
1560        );
1561    }
1562
1563    #[cfg(feature = "perl")]
1564    #[test]
1565    fn perl_no_exit() {
1566        check_metrics::<PerlParser>(
1567            "sub f {
1568                print 'hi';
1569            }",
1570            "foo.pl",
1571            |metric| {
1572                insta::assert_json_snapshot!(
1573                    metric.nexits,
1574                    @r#"
1575                {
1576                  "sum": 0,
1577                  "average": 0.0,
1578                  "min": 0,
1579                  "max": 0
1580                }
1581                "#
1582                );
1583            },
1584        );
1585    }
1586
1587    #[cfg(feature = "perl")]
1588    #[test]
1589    fn perl_no_function_no_exit() {
1590        check_metrics::<PerlParser>("my $x = 1;\nprint $x;\n", "foo.pl", |metric| {
1591            insta::assert_json_snapshot!(metric.nexits, @r#"
1592            {
1593              "sum": 0,
1594              "average": 0.0,
1595              "min": 0,
1596              "max": 0
1597            }
1598            "#);
1599        });
1600    }
1601
1602    #[cfg(feature = "perl")]
1603    #[test]
1604    fn perl_multiple_returns() {
1605        check_metrics::<PerlParser>(
1606            "sub f {
1607                return 1 if $_[0];
1608                return 0;
1609            }",
1610            "foo.pl",
1611            |metric| {
1612                insta::assert_json_snapshot!(
1613                    metric.nexits,
1614                    @r#"
1615                {
1616                  "sum": 2,
1617                  "average": 2.0,
1618                  "min": 0,
1619                  "max": 2
1620                }
1621                "#
1622                );
1623            },
1624        );
1625    }
1626
1627    /// `die` and `exit` are Perl's abrupt-exit builtins (#1270). Every
1628    /// call form nests one `call_expression_with_bareword`, so the
1629    /// spaced-args (`die "m"`), bracketed (`exit(1)`) and bare
1630    /// (`die;`) spellings each count exactly once — a wrapper node is
1631    /// never counted alongside its bareword. The `CORE::`-qualified
1632    /// spelling of each is the same builtin reached past an override
1633    /// and keeps its qualifier in the bareword text, so it is matched
1634    /// by name alongside the bare one.
1635    #[cfg(feature = "perl")]
1636    #[test]
1637    fn perl_die_and_exit_are_exits() {
1638        check_metrics::<PerlParser>(
1639            "sub f {
1640                die \"bad\" if $_[0];
1641                open(my $fh, '<', $p) or die;
1642                exit(1) if $_[1];
1643                exit 2;
1644                CORE::die \"forced\" if $_[2];
1645                CORE::exit(3);
1646                return 0;
1647            }",
1648            "foo.pl",
1649            |metric| {
1650                // expected: 6 abrupt exits (two `die`, two `exit`, and
1651                // the `CORE::` spelling of each) plus one `return`.
1652                assert_eq!(metric.nexits.nexits_sum(), 7);
1653            },
1654        );
1655    }
1656
1657    /// Only the unqualified builtins count. A package-qualified callee
1658    /// keeps its qualifier in the bareword text (`Carp::croak`), the
1659    /// Carp helpers are library functions rather than builtins, and
1660    /// `$obj->die` parses as a `method_invocation` whose callee is a
1661    /// plain `identifier` — none of them is an exit.
1662    #[cfg(feature = "perl")]
1663    #[test]
1664    fn perl_lookalike_call_is_not_exit() {
1665        check_metrics::<PerlParser>(
1666            "sub f {
1667                $obj->die;
1668                $obj->exit(1);
1669                Carp::croak(\"x\");
1670                croak \"x\";
1671                my $s = \"die\";
1672            }",
1673            "foo.pl",
1674            |metric| {
1675                assert_eq!(metric.nexits.nexits_sum(), 0);
1676            },
1677        );
1678    }
1679
1680    /// The root space's `nexits` sum for the Perl file `source`.
1681    #[cfg(feature = "perl")]
1682    fn perl_nexits(source: &str) -> u64 {
1683        metrics_verbatim(
1684            LANG::Perl,
1685            source.as_bytes(),
1686            MetricsOptions::default().with_only(&[Metric::Nexits]),
1687        )
1688        .nexits
1689        .nexits_sum()
1690    }
1691
1692    /// Each auto-quoted `die` / `exit` key scores exactly as its quoted
1693    /// twin does (#1585). tree-sitter-perl reads `die` before `=>` as a
1694    /// bareword call (#1571 upstream), so the `=>` rows each counted one
1695    /// exit per key before the fix: the first row read 3. The subscript
1696    /// and dash rows never parse to a `die` / `exit` bareword at all
1697    /// (`$h{die}` is an `identifier`, `-exit` a file test on `xit`); they
1698    /// pin that the gate keeps them at the twin's reading. `- die =>`
1699    /// negates the quoted string, and a newline before the `=>` does not
1700    /// stop it quoting.
1701    #[cfg(feature = "perl")]
1702    #[test]
1703    fn perl_exit_key_scores_like_its_quoted_twin() {
1704        let rows: [(&str, u64); 9] = [
1705            (
1706                "sub f { my %h = ('exit' => 1, 'die' => 2); return 1; }\n",
1707                1,
1708            ),
1709            ("sub f { my %h = (- 'die' => 1); return 1; }\n", 1),
1710            ("sub f { my %h = ('exit'\n => 1); return 1; }\n", 1),
1711            (
1712                "sub f { my $r = { 'die' => 1, 'exit' => 2 }; return $r; }\n",
1713                1,
1714            ),
1715            ("sub f { g('exit' => 1); return; }\n", 1),
1716            (
1717                "sub f { my %h = ('-die' => 1, '-exit' => 2); return 1; }\n",
1718                1,
1719            ),
1720            ("sub f { my $v = $h{'die'}; return $v; }\n", 1),
1721            ("sub f { my $v = $h{'-exit'}; return $v; }\n", 1),
1722            ("sub f { my %h = ('exit' => 1); exit 2; }\n", 1),
1723        ];
1724        for (twin, want) in rows {
1725            let key = twin.replace('\'', "");
1726            assert_eq!(perl_nexits(twin), want, "`{twin}` moved; re-derive the row");
1727            assert_eq!(perl_nexits(&key), want, "`{key}` must score like `{twin}`");
1728        }
1729    }
1730
1731    /// A `die` / `exit` that is no auto-quoted key keeps its exit: every
1732    /// call spelling, a call inside a subscript, a call before `=>`
1733    /// (`die()` takes arguments, so `=>` does not quote it), a value
1734    /// after `=>`, and the `CORE::` spelling, which `=>` does not quote
1735    /// either since `::` is no word character. Every value is `main`'s,
1736    /// measured before #1585.
1737    #[cfg(feature = "perl")]
1738    #[test]
1739    fn perl_exit_outside_a_key_keeps_its_exit() {
1740        let rows: [(&str, u64); 9] = [
1741            ("sub f { die \"x\"; }\n", 1),
1742            ("sub f { exit 1; }\n", 1),
1743            ("sub f { exit; }\n", 1),
1744            ("sub f { open(my $fh, '<', 'p') or die \"x\"; }\n", 1),
1745            ("sub f { my $v = $h{die()}; }\n", 1),
1746            ("sub f { my $v = $h{exit()}; }\n", 1),
1747            ("sub f { my %h = (die() => 1); }\n", 1),
1748            ("sub f { my %h = (x => die); }\n", 1),
1749            ("sub f { my %h = (CORE::die => 1); }\n", 1),
1750        ];
1751        for (source, want) in rows {
1752            assert_eq!(perl_nexits(source), want, "`{source}` changed its reading");
1753        }
1754    }
1755
1756    /// A file whose tail fails to parse leaves every key and call in it a
1757    /// direct child of one flat `ERROR` root. The key test reads the
1758    /// token after each `die`, which the metric walk's `SiblingCache`
1759    /// answers (#1584); a walk that lent the cache without recording the
1760    /// `ERROR` would trip the debug assertion in that lookup. The reading
1761    /// is the twelve `exit` calls, as the quoted twin's is.
1762    #[cfg(feature = "perl")]
1763    #[test]
1764    fn perl_exit_key_in_a_flat_error_scores_like_its_twin() {
1765        // The width from which a walk records a node's children.
1766        use big_code_analysis_ast::node::WIDE_PARENT;
1767        let key = format!("my @r = ({}", "{\ndie => 1,\nexit,\n".repeat(12));
1768        let twin = key.replace("die =>", "'die' =>");
1769        let ast = parse_named(LANG::Perl, "foo.pl", &key);
1770        let root = ast.as_tree_sitter().root_node();
1771        assert!(
1772            root.is_error() && root.child_count() > WIDE_PARENT,
1773            "the fixture must parse to a wide flat ERROR root"
1774        );
1775        assert_eq!(perl_nexits(&twin), 12);
1776        assert_eq!(perl_nexits(&key), 12);
1777    }
1778
1779    #[cfg(feature = "typescript")]
1780    #[test]
1781    fn tsx_function_with_returns() {
1782        check_metrics::<TsxParser>(
1783            "function clamp(val: number, min: number, max: number) {
1784                 if (val < min) {
1785                     return min;
1786                 }
1787                 if (val > max) {
1788                     return max;
1789                 }
1790                 return val;
1791             }",
1792            "foo.tsx",
1793            |metric| {
1794                insta::assert_json_snapshot!(
1795                    metric.nexits,
1796                    @r#"
1797                {
1798                  "sum": 3,
1799                  "average": 3.0,
1800                  "min": 0,
1801                  "max": 3
1802                }
1803                "#
1804                );
1805            },
1806        );
1807    }
1808
1809    #[cfg(feature = "typescript")]
1810    #[test]
1811    fn typescript_no_exit() {
1812        check_metrics::<TypescriptParser>("const x: number = 42;", "foo.ts", |metric| {
1813            insta::assert_json_snapshot!(
1814                metric.nexits,
1815                @r#"
1816            {
1817              "sum": 0,
1818              "average": 0.0,
1819              "min": 0,
1820              "max": 0
1821            }
1822            "#
1823            );
1824        });
1825    }
1826
1827    #[cfg(feature = "typescript")]
1828    #[test]
1829    fn typescript_function_with_returns() {
1830        check_metrics::<TypescriptParser>(
1831            "function safeDivide(a: number, b: number): number | null {
1832                 if (b === 0) {
1833                     return null;
1834                 }
1835                 return a / b;
1836             }",
1837            "foo.ts",
1838            |metric| {
1839                insta::assert_json_snapshot!(
1840                    metric.nexits,
1841                    @r#"
1842                {
1843                  "sum": 2,
1844                  "average": 2.0,
1845                  "min": 0,
1846                  "max": 2
1847                }
1848                "#
1849                );
1850            },
1851        );
1852    }
1853
1854    #[cfg(feature = "mozjs")]
1855    #[test]
1856    fn mozjs_no_exit() {
1857        check_metrics::<MozjsParser>("var a = 42;", "foo.js", |metric| {
1858            insta::assert_json_snapshot!(
1859                metric.nexits,
1860                @r#"
1861            {
1862              "sum": 0,
1863              "average": 0.0,
1864              "min": 0,
1865              "max": 0
1866            }
1867            "#
1868            );
1869        });
1870    }
1871
1872    #[cfg(feature = "mozjs")]
1873    #[test]
1874    fn mozjs_function_with_returns() {
1875        check_metrics::<MozjsParser>(
1876            "function f(a, b) {
1877                 if (a) {
1878                     return a;
1879                 }
1880                 return b;
1881             }",
1882            "foo.js",
1883            |metric| {
1884                insta::assert_json_snapshot!(
1885                    metric.nexits,
1886                    @r#"
1887                {
1888                  "sum": 2,
1889                  "average": 2.0,
1890                  "min": 0,
1891                  "max": 2
1892                }
1893                "#
1894                );
1895            },
1896        );
1897    }
1898
1899    #[cfg(feature = "kotlin")]
1900    #[test]
1901    fn kotlin_exit_return_and_throw() {
1902        check_metrics::<KotlinParser>(
1903            "fun divide(a: Int, b: Int): Int {
1904                if (b == 0) {
1905                    throw IllegalArgumentException(\"zero\")
1906                }
1907                return a / b
1908            }",
1909            "foo.kt",
1910            |metric| {
1911                insta::assert_json_snapshot!(
1912                    metric.nexits,
1913                    @r#"
1914                {
1915                  "sum": 2,
1916                  "average": 2.0,
1917                  "min": 0,
1918                  "max": 2
1919                }
1920                "#
1921                );
1922            },
1923        );
1924    }
1925
1926    #[cfg(feature = "lua")]
1927    #[test]
1928    fn lua_no_exit() {
1929        check_metrics::<LuaParser>(
1930            "local function f(x)
1931  local y = x + 1
1932end",
1933            "foo.lua",
1934            |metric| {
1935                insta::assert_json_snapshot!(
1936                    metric.nexits,
1937                    @r#"
1938                {
1939                  "sum": 0,
1940                  "average": 0.0,
1941                  "min": 0,
1942                  "max": 0
1943                }
1944                "#
1945                );
1946            },
1947        );
1948    }
1949
1950    #[cfg(feature = "lua")]
1951    #[test]
1952    fn lua_return() {
1953        check_metrics::<LuaParser>(
1954            "local function f(x)
1955  if x > 0 then
1956    return x
1957  end
1958  return 0
1959end",
1960            "foo.lua",
1961            |metric| {
1962                insta::assert_json_snapshot!(
1963                    metric.nexits,
1964                    @r#"
1965                {
1966                  "sum": 2,
1967                  "average": 2.0,
1968                  "min": 0,
1969                  "max": 2
1970                }
1971                "#
1972                );
1973            },
1974        );
1975    }
1976
1977    #[cfg(feature = "lua")]
1978    #[test]
1979    fn lua_error_counts_as_exit() {
1980        check_metrics::<LuaParser>(
1981            "local function f(x)
1982  error(\"bad\")
1983end",
1984            "foo.lua",
1985            |metric| {
1986                // error(...) raises a Lua error that unwinds the stack — a
1987                // built-in abrupt exit, counted like throw/raise.
1988                insta::assert_json_snapshot!(
1989                    metric.nexits,
1990                    @r#"
1991                {
1992                  "sum": 1,
1993                  "average": 1.0,
1994                  "min": 0,
1995                  "max": 1
1996                }
1997                "#
1998                );
1999            },
2000        );
2001    }
2002
2003    #[cfg(feature = "lua")]
2004    #[test]
2005    fn lua_os_exit_counts_as_exit() {
2006        check_metrics::<LuaParser>(
2007            "local function f()
2008  os.exit(1)
2009end",
2010            "foo.lua",
2011            |metric| {
2012                // os.exit(...) terminates the process — its callee is a
2013                // dot_index_expression spelling `os.exit`, counted as an exit.
2014                insta::assert_json_snapshot!(
2015                    metric.nexits,
2016                    @r#"
2017                {
2018                  "sum": 1,
2019                  "average": 1.0,
2020                  "min": 0,
2021                  "max": 1
2022                }
2023                "#
2024                );
2025            },
2026        );
2027    }
2028
2029    #[cfg(feature = "lua")]
2030    #[test]
2031    fn lua_error_and_return_both_count() {
2032        check_metrics::<LuaParser>(
2033            "local function f(x)
2034  if x < 0 then
2035    error(\"negative\")
2036  end
2037  return x
2038end",
2039            "foo.lua",
2040            |metric| {
2041                // error(...) + return are both abrupt exits → 2.
2042                insta::assert_json_snapshot!(
2043                    metric.nexits,
2044                    @r#"
2045                {
2046                  "sum": 2,
2047                  "average": 2.0,
2048                  "min": 0,
2049                  "max": 2
2050                }
2051                "#
2052                );
2053            },
2054        );
2055    }
2056
2057    #[cfg(feature = "lua")]
2058    #[test]
2059    fn lua_user_call_is_not_exit() {
2060        check_metrics::<LuaParser>(
2061            "local function f()
2062  foo()
2063  myError(\"x\")
2064end",
2065            "foo.lua",
2066            |metric| {
2067                // Neither `foo()` nor a user `myError(...)` is the built-in
2068                // `error`/`os.exit`, so neither is counted.
2069                insta::assert_json_snapshot!(
2070                    metric.nexits,
2071                    @r#"
2072                {
2073                  "sum": 0,
2074                  "average": 0.0,
2075                  "min": 0,
2076                  "max": 0
2077                }
2078                "#
2079                );
2080            },
2081        );
2082    }
2083
2084    #[cfg(feature = "bash")]
2085    #[test]
2086    fn bash_no_exit() {
2087        check_metrics::<BashParser>("echo \"no exits\"", "foo.sh", |metric| {
2088            insta::assert_json_snapshot!(
2089                metric.nexits,
2090                @r#"
2091            {
2092              "sum": 0,
2093              "average": 0.0,
2094              "min": 0,
2095              "max": 0
2096            }
2097            "#
2098            );
2099        });
2100    }
2101
2102    #[cfg(feature = "bash")]
2103    #[test]
2104    fn bash_explicit_return() {
2105        check_metrics::<BashParser>(
2106            "f() {
2107                 if [ -z \"$1\" ]; then
2108                     return 1
2109                 fi
2110                 echo ok
2111             }",
2112            "foo.sh",
2113            |metric| {
2114                insta::assert_json_snapshot!(
2115                    metric.nexits,
2116                    @r#"
2117                {
2118                  "sum": 1,
2119                  "average": 1.0,
2120                  "min": 0,
2121                  "max": 1
2122                }
2123                "#
2124                );
2125            },
2126        );
2127    }
2128
2129    #[cfg(feature = "bash")]
2130    #[test]
2131    fn bash_explicit_exit() {
2132        check_metrics::<BashParser>(
2133            "f() {
2134                 exit 0
2135             }",
2136            "foo.sh",
2137            |metric| {
2138                insta::assert_json_snapshot!(
2139                    metric.nexits,
2140                    @r#"
2141                {
2142                  "sum": 1,
2143                  "average": 1.0,
2144                  "min": 0,
2145                  "max": 1
2146                }
2147                "#
2148                );
2149            },
2150        );
2151    }
2152
2153    #[cfg(feature = "bash")]
2154    #[test]
2155    fn bash_multiple_exits() {
2156        check_metrics::<BashParser>(
2157            "f() {
2158                 if [ \"$1\" = die ]; then
2159                     exit 1
2160                 fi
2161                 return 0
2162             }",
2163            "foo.sh",
2164            |metric| {
2165                insta::assert_json_snapshot!(
2166                    metric.nexits,
2167                    @r#"
2168                {
2169                  "sum": 2,
2170                  "average": 2.0,
2171                  "min": 0,
2172                  "max": 2
2173                }
2174                "#
2175                );
2176            },
2177        );
2178    }
2179
2180    #[cfg(feature = "bash")]
2181    #[test]
2182    fn bash_returnish_names_are_not_exits() {
2183        // `returncode=1` is a `variable_assignment`, not a Command. The
2184        // function `returns` is invoked via a Command whose CommandName is
2185        // the literal "returns" — it must NOT be matched as a return/exit
2186        // builtin (whole-token match, no prefix collision).
2187        check_metrics::<BashParser>(
2188            "returncode=1
2189             returns() {
2190                 echo named
2191             }
2192             returns",
2193            "foo.sh",
2194            |metric| {
2195                insta::assert_json_snapshot!(
2196                    metric.nexits,
2197                    @r#"
2198                {
2199                  "sum": 0,
2200                  "average": 0.0,
2201                  "min": 0,
2202                  "max": 0
2203                }
2204                "#
2205                );
2206            },
2207        );
2208    }
2209
2210    #[cfg(feature = "tcl")]
2211    #[test]
2212    fn tcl_no_exit() {
2213        check_metrics::<TclParser>(
2214            "proc f {x} {
2215    puts $x
2216}",
2217            "foo.tcl",
2218            |metric| {
2219                insta::assert_json_snapshot!(
2220                    metric.nexits,
2221                    @r#"
2222                {
2223                  "sum": 0,
2224                  "average": 0.0,
2225                  "min": 0,
2226                  "max": 0
2227                }
2228                "#
2229                );
2230            },
2231        );
2232    }
2233
2234    #[cfg(feature = "tcl")]
2235    #[test]
2236    fn tcl_return() {
2237        check_metrics::<TclParser>(
2238            "proc f {x} {
2239    return $x
2240}",
2241            "foo.tcl",
2242            |metric| {
2243                assert_eq!(metric.nexits.nexits_sum(), 1);
2244                assert_eq!(metric.nexits.nexits_max(), 1);
2245                insta::assert_json_snapshot!(metric.nexits);
2246            },
2247        );
2248    }
2249
2250    #[cfg(feature = "tcl")]
2251    #[test]
2252    fn tcl_multiple_returns() {
2253        check_metrics::<TclParser>(
2254            "proc f {x} {
2255    if {$x > 0} {
2256        return positive
2257    }
2258    return nonpositive
2259}",
2260            "foo.tcl",
2261            |metric| {
2262                assert_eq!(metric.nexits.nexits_sum(), 2);
2263                assert_eq!(metric.nexits.nexits_max(), 2);
2264                insta::assert_json_snapshot!(metric.nexits);
2265            },
2266        );
2267    }
2268
2269    /// Tcl's abrupt-exit builtins have no dedicated grammar rule:
2270    /// `error`, the 8.6 `throw`, and `exit` all parse as generic
2271    /// commands told apart by their leading word, the same seam
2272    /// `return` uses (#1270).
2273    #[cfg(feature = "tcl")]
2274    #[test]
2275    fn tcl_error_and_throw_are_exits() {
2276        check_metrics::<TclParser>(
2277            "proc f {x} {
2278    if {$x < 0} {
2279        error \"negative\"
2280    }
2281    if {$x == 0} {
2282        throw {ARITH DIVZERO} \"div by zero\"
2283    }
2284    if {$x > 100} {
2285        exit 1
2286    }
2287    return $x
2288}",
2289            "foo.tcl",
2290            |metric| {
2291                // expected: `error` + `throw` + `exit` + `return` = 4.
2292                assert_eq!(metric.nexits.nexits_sum(), 4);
2293            },
2294        );
2295    }
2296
2297    /// The command *name* is the seam, so the same words in argument
2298    /// position (`puts error`) or inside a string are not exits. The
2299    /// leading word of a nested braced command (`{ARITH DIVZERO}`) is
2300    /// likewise a different command name and contributes nothing.
2301    /// Every abrupt-exit builtin reached through the global namespace.
2302    /// `::return` *is* `return`, so all four must count; the leading word
2303    /// is the only seam these have, and an unstripped qualifier made each
2304    /// read as an ordinary call (#1381 review).
2305    #[cfg(feature = "tcl")]
2306    #[test]
2307    fn tcl_qualified_exits_are_exits() {
2308        check_metrics::<TclParser>(
2309            "proc f {x} {
2310    if {$x < 0} {
2311        ::error \"negative\"
2312    }
2313    if {$x == 0} {
2314        ::throw {ARITH DIVZERO} \"div by zero\"
2315    }
2316    if {$x > 100} {
2317        ::exit 1
2318    }
2319    ::return $x
2320}",
2321            "foo.tcl",
2322            |metric| {
2323                assert_eq!(metric.nexits.nexits_sum(), 4);
2324                assert_eq!(metric.nexits.nexits_max(), 4);
2325            },
2326        );
2327    }
2328
2329    /// Control for the test above: only the *leading* qualifier resolves
2330    /// to the core command, so a proc in `ns` is not an exit.
2331    #[cfg(feature = "tcl")]
2332    #[test]
2333    fn tcl_namespaced_return_is_not_an_exit() {
2334        check_metrics::<TclParser>(
2335            "proc f {x} {
2336    ns::return $x
2337}",
2338            "foo.tcl",
2339            |metric| {
2340                assert_eq!(metric.nexits.nexits_sum(), 0);
2341            },
2342        );
2343    }
2344
2345    #[cfg(feature = "tcl")]
2346    #[test]
2347    fn tcl_error_in_argument_position_is_not_exit() {
2348        check_metrics::<TclParser>(
2349            "proc f {x} {
2350    puts error
2351    puts throw
2352    set y \"error\"
2353}",
2354            "foo.tcl",
2355            |metric| {
2356                assert_eq!(metric.nexits.nexits_sum(), 0);
2357            },
2358        );
2359    }
2360
2361    #[cfg(feature = "typescript")]
2362    #[test]
2363    fn typescript_multiple_returns() {
2364        check_metrics::<TypescriptParser>(
2365            "function classify(n: number): string {
2366             if (n > 0) {
2367                 return 'positive';
2368             } else if (n < 0) {
2369                 return 'negative';
2370             }
2371             return 'zero';
2372         }",
2373            "foo.ts",
2374            |metric| {
2375                assert_eq!(metric.nexits.nexits_sum(), 3);
2376                assert_eq!(metric.nexits.nexits_max(), 3);
2377                insta::assert_json_snapshot!(metric.nexits);
2378            },
2379        );
2380    }
2381
2382    #[cfg(feature = "typescript")]
2383    #[test]
2384    fn typescript_nested_functions() {
2385        check_metrics::<TypescriptParser>(
2386            "function outer(): number {
2387             function inner(): number {
2388                 return 42;
2389             }
2390             return inner();
2391         }",
2392            "foo.ts",
2393            |metric| {
2394                // outer has 1 return, inner has 1 return → sum=2, max=1
2395                assert_eq!(metric.nexits.nexits_sum(), 2);
2396                assert_eq!(metric.nexits.nexits_max(), 1);
2397                insta::assert_json_snapshot!(metric.nexits);
2398            },
2399        );
2400    }
2401
2402    #[cfg(feature = "typescript")]
2403    #[test]
2404    fn tsx_no_exit() {
2405        check_metrics::<TsxParser>(
2406            "function f(): void {
2407             console.log('hello');
2408         }",
2409            "foo.tsx",
2410            |metric| {
2411                assert_eq!(metric.nexits.nexits_sum(), 0);
2412                assert_eq!(metric.nexits.nexits_max(), 0);
2413                insta::assert_json_snapshot!(metric.nexits);
2414            },
2415        );
2416    }
2417
2418    #[cfg(feature = "typescript")]
2419    #[test]
2420    fn tsx_multiple_returns() {
2421        check_metrics::<TsxParser>(
2422            "function classify(n: number): string {
2423             if (n > 0) {
2424                 return 'positive';
2425             } else if (n < 0) {
2426                 return 'negative';
2427             }
2428             return 'zero';
2429         }",
2430            "foo.tsx",
2431            |metric| {
2432                assert_eq!(metric.nexits.nexits_sum(), 3);
2433                assert_eq!(metric.nexits.nexits_max(), 3);
2434                insta::assert_json_snapshot!(metric.nexits);
2435            },
2436        );
2437    }
2438
2439    #[cfg(feature = "kotlin")]
2440    #[test]
2441    fn kotlin_multiple_returns() {
2442        check_metrics::<KotlinParser>(
2443            "fun classify(n: Int): String {
2444             if (n > 0) {
2445                 return \"positive\"
2446             } else if (n < 0) {
2447                 return \"negative\"
2448             }
2449             return \"zero\"
2450         }",
2451            "foo.kt",
2452            |metric| {
2453                assert_eq!(metric.nexits.nexits_sum(), 3);
2454                assert_eq!(metric.nexits.nexits_max(), 3);
2455                insta::assert_json_snapshot!(metric.nexits);
2456            },
2457        );
2458    }
2459
2460    #[cfg(feature = "kotlin")]
2461    #[test]
2462    fn kotlin_no_exit() {
2463        check_metrics::<KotlinParser>(
2464            "fun f(): Unit {
2465             println(\"hello\")
2466         }",
2467            "foo.kt",
2468            |metric| {
2469                assert_eq!(metric.nexits.nexits_sum(), 0);
2470                assert_eq!(metric.nexits.nexits_max(), 0);
2471                insta::assert_json_snapshot!(metric.nexits);
2472            },
2473        );
2474    }
2475
2476    #[cfg(feature = "mozjs")]
2477    #[test]
2478    fn mozjs_nested_functions() {
2479        check_metrics::<MozjsParser>(
2480            "function outer() {
2481             function inner() {
2482                 return 42;
2483             }
2484             return inner();
2485         }",
2486            "foo.js",
2487            |metric| {
2488                // outer has 1 return, inner has 1 return → sum=2, max=1
2489                assert_eq!(metric.nexits.nexits_sum(), 2);
2490                assert_eq!(metric.nexits.nexits_max(), 1);
2491                insta::assert_json_snapshot!(metric.nexits);
2492            },
2493        );
2494    }
2495
2496    #[cfg(feature = "php")]
2497    #[test]
2498    fn php_no_exit() {
2499        check_metrics::<PhpParser>("<?php $a = 42;", "foo.php", |metric| {
2500            insta::assert_json_snapshot!(
2501                metric.nexits,
2502                @r#"
2503            {
2504              "sum": 0,
2505              "average": 0.0,
2506              "min": 0,
2507              "max": 0
2508            }
2509            "#
2510            );
2511        });
2512    }
2513
2514    #[cfg(feature = "php")]
2515    #[test]
2516    fn php_yield_throw() {
2517        // Generator yields and a throw expression in statement position both
2518        // count as exits.
2519        check_metrics::<PhpParser>(
2520            "<?php
2521            function gen() {
2522                yield 1;
2523                yield 2;
2524                throw new \\Exception('x');
2525            }",
2526            "foo.php",
2527            |metric| {
2528                // 3 exits (2 yields + 1 throw) inside one function space.
2529                insta::assert_json_snapshot!(
2530                    metric.nexits,
2531                    @r#"
2532                {
2533                  "sum": 3,
2534                  "average": 3.0,
2535                  "min": 0,
2536                  "max": 3
2537                }
2538                "#
2539                );
2540            },
2541        );
2542    }
2543
2544    #[cfg(feature = "php")]
2545    #[test]
2546    fn php_exit_statement() {
2547        // `exit_statement` covers both `exit;` (bare) and `exit(N);` (with
2548        // optional argument). `die` is NOT in the `exit_statement` rule of
2549        // tree-sitter-php 0.25.1 — `die(...)` parses as a function call —
2550        // so we only count `exit` here.
2551        check_metrics::<PhpParser>(
2552            "<?php
2553            function bail(int $code): void {
2554                if ($code === 1) {
2555                    exit(1);
2556                }
2557                exit;
2558            }",
2559            "foo.php",
2560            |metric| {
2561                // 2 exit_statements inside one function space.
2562                insta::assert_json_snapshot!(
2563                    metric.nexits,
2564                    @r#"
2565                {
2566                  "sum": 2,
2567                  "average": 2.0,
2568                  "min": 0,
2569                  "max": 2
2570                }
2571                "#
2572                );
2573            },
2574        );
2575    }
2576
2577    #[cfg(feature = "elixir")]
2578    #[test]
2579    fn elixir_no_exit() {
2580        // Plain function returning a value has no early-exit calls. The
2581        // `average` is `null` because Elixir's only function space is
2582        // the Unit; there is no per-function aggregation to average
2583        // over.
2584        check_metrics::<ElixirParser>(
2585            "defmodule Foo do\n  def add(a, b) do\n    a + b\n  end\nend\n",
2586            "foo.ex",
2587            |metric| {
2588                assert_eq!(metric.nexits.nexits_sum(), 0);
2589                insta::assert_json_snapshot!(
2590                    metric.nexits,
2591                    @r#"
2592                {
2593                  "sum": 0,
2594                  "average": 0.0,
2595                  "min": 0,
2596                  "max": 0
2597                }
2598                "#
2599                );
2600            },
2601        );
2602    }
2603
2604    #[cfg(feature = "elixir")]
2605    #[test]
2606    fn elixir_raise_throw_exit() {
2607        // `raise`/`throw`/`exit` are recognised by inspecting the `target`
2608        // field text of `Call` nodes — there is no dedicated AST kind.
2609        check_metrics::<ElixirParser>(
2610            "defmodule Foo do\n  def bad(x) do\n    raise \"first\"\n    throw(:second)\n    exit(:third)\n  end\nend\n",
2611            "foo.ex",
2612            |metric| {
2613                assert_eq!(metric.nexits.nexits_sum(), 3);
2614                insta::assert_json_snapshot!(
2615                    metric.nexits,
2616                    @r#"
2617                {
2618                  "sum": 3,
2619                  "average": 3.0,
2620                  "min": 0,
2621                  "max": 3
2622                }
2623                "#
2624                );
2625            },
2626        );
2627    }
2628
2629    #[cfg(feature = "elixir")]
2630    #[test]
2631    fn elixir_reraise_counts() {
2632        // `reraise` is the Elixir variant of `raise` that re-throws an
2633        // existing exception while preserving the stacktrace; we count
2634        // it as an exit alongside `raise`.
2635        check_metrics::<ElixirParser>(
2636            "defmodule Foo do\n  def wrap(stack) do\n    reraise(\"oops\", stack)\n  end\nend\n",
2637            "foo.ex",
2638            |metric| {
2639                assert_eq!(metric.nexits.nexits_sum(), 1);
2640            },
2641        );
2642    }
2643
2644    #[cfg(feature = "elixir")]
2645    #[test]
2646    fn elixir_lookalike_call_is_not_exit() {
2647        // Only the exact identifiers `throw`/`raise`/`reraise`/`exit` are
2648        // exits; a user-defined `throw_event` or remote-call must NOT
2649        // count. This guards against future text-match regressions.
2650        check_metrics::<ElixirParser>(
2651            "defmodule Foo do\n  def f do\n    throw_event(:click)\n    Logger.raise_alert()\n    exit_code = 0\n    exit_code\n  end\nend\n",
2652            "foo.ex",
2653            |metric| {
2654                assert_eq!(metric.nexits.nexits_sum(), 0);
2655            },
2656        );
2657    }
2658
2659    #[cfg(feature = "ruby")]
2660    #[test]
2661    fn ruby_no_exit() {
2662        // Function body without any `return` produces zero exits.
2663        check_metrics::<RubyParser>("def foo\n  a = 1\n  a + 1\nend\n", "foo.rb", |metric| {
2664            assert_eq!(metric.nexits.nexits_sum(), 0);
2665        });
2666    }
2667
2668    #[cfg(feature = "ruby")]
2669    #[test]
2670    fn ruby_multiple_returns() {
2671        // Four explicit `return` statements (no modifier sugar) — one
2672        // per branch. Anchors the headline sum.
2673        check_metrics::<RubyParser>(
2674            "def kind(x)\n  return :zero if x == 0\n  if x > 0\n    return :pos\n  elsif x < 0\n    return :neg\n  end\n  return :unknown\nend\n",
2675            "foo.rb",
2676            |metric| {
2677                assert_eq!(metric.nexits.nexits_sum(), 4);
2678            },
2679        );
2680    }
2681
2682    #[cfg(feature = "ruby")]
2683    #[test]
2684    fn ruby_explicit_returns() {
2685        // Each `return` (statement or modifier-wrapped) contributes one
2686        // exit. `yield` is intentionally NOT counted (it does not exit
2687        // the method).
2688        check_metrics::<RubyParser>(
2689            "def foo(x)\n  return 0 if x.nil?\n  yield x\n  return x * 2\nend\n",
2690            "foo.rb",
2691            |metric| {
2692                assert_eq!(metric.nexits.nexits_sum(), 2);
2693                insta::assert_json_snapshot!(metric.nexits);
2694            },
2695        );
2696    }
2697
2698    /// `raise` and `exit` are receiver-less Kernel calls with no
2699    /// grammar node of their own, matched by callee text the way Go
2700    /// matches `panic` (#1270). Both the paren-less command form
2701    /// (`raise ArgumentError, "m"`) and the parenthesised form
2702    /// (`exit(1)`) parse as `call`, so both count.
2703    #[cfg(feature = "ruby")]
2704    #[test]
2705    fn ruby_raise_and_exit_are_exits() {
2706        check_metrics::<RubyParser>(
2707            "def f(x)\n  raise ArgumentError, \"bad\" if x\n  raise(RuntimeError)\n  exit 1 if x\n  exit(2)\n  return 0\nend\n",
2708            "foo.rb",
2709            |metric| {
2710                // expected: two `raise` + two `exit` + one `return` = 5.
2711                assert_eq!(metric.nexits.nexits_sum(), 5);
2712            },
2713        );
2714    }
2715
2716    /// The explicit `Kernel.` receiver *is* the builtin — it is what code
2717    /// writes when a DSL has shadowed the bare name — and `exit!` is
2718    /// `Kernel#exit!`, the immediate process exit. Both count. A bare
2719    /// `exit!` counts too, unlike a bare `exit`: the `!` cannot name a
2720    /// local, so the grammar emits a `call` rather than an `identifier`.
2721    #[cfg(feature = "ruby")]
2722    #[test]
2723    fn ruby_kernel_qualified_and_bang_exits_count() {
2724        check_metrics::<RubyParser>(
2725            "def f(x)\n  Kernel.raise ArgumentError, \"bad\" if x\n  Kernel.exit(1) if x\n  exit! if x\n  exit!\nend\n",
2726            "foo.rb",
2727            |metric| {
2728                // expected: `Kernel.raise` + `Kernel.exit` + two `exit!` = 4.
2729                assert_eq!(metric.nexits.nexits_sum(), 4);
2730            },
2731        );
2732    }
2733
2734    /// A call with any receiver other than `Kernel` is a user method,
2735    /// never the builtin, so `obj.raise` / `self.exit` must not count —
2736    /// the same bare-callee gate Go uses to keep `foo.panic()` out. A
2737    /// symbol or hash key spelling the builtin parses as
2738    /// `simple_symbol` / `hash_key_symbol` and is likewise not a call.
2739    #[cfg(feature = "ruby")]
2740    #[test]
2741    fn ruby_receiver_call_is_not_exit() {
2742        check_metrics::<RubyParser>(
2743            "def f(x)\n  obj.raise(x)\n  self.exit(1)\n  logger.raise\n  h = { raise: 1 }\n  s = :exit\nend\n",
2744            "foo.rb",
2745            |metric| {
2746                assert_eq!(metric.nexits.nexits_sum(), 0);
2747            },
2748        );
2749    }
2750
2751    /// A *bare* `raise` / `exit` — the argument-less re-raise idiom —
2752    /// parses as a plain `identifier`, indistinguishable from reading a
2753    /// local variable of that name, so it is deliberately not counted.
2754    /// Pinning the exclusion here keeps a future "just match bare
2755    /// identifiers too" change from silently counting every variable
2756    /// read.
2757    #[cfg(feature = "ruby")]
2758    #[test]
2759    fn ruby_bare_raise_identifier_is_not_exit() {
2760        check_metrics::<RubyParser>(
2761            "def f(x)\n  begin\n    g(x)\n  rescue StandardError\n    raise\n  end\n  return 0\nend\n",
2762            "foo.rb",
2763            |metric| {
2764                // expected: only the `return`; the bare `raise` is an
2765                // `identifier`, not a `call`.
2766                assert_eq!(metric.nexits.nexits_sum(), 1);
2767            },
2768        );
2769    }
2770
2771    #[cfg(feature = "python")]
2772    #[test]
2773    fn python_return_and_raise() {
2774        // `raise` exits the function (stack unwinds)
2775        // just like `return`. Mirrors the C# / Kotlin / PHP / Elixir
2776        // behaviour. One `raise` + one `return` => 2 exits.
2777        check_metrics::<PythonParser>(
2778            "def parse(s):
2779                 if not s:
2780                     raise ValueError(\"empty\")
2781                 return int(s)",
2782            "foo.py",
2783            |metric| {
2784                assert_eq!(metric.nexits.nexits_sum(), 2);
2785                insta::assert_json_snapshot!(
2786                    metric.nexits,
2787                    @r#"
2788                {
2789                  "sum": 2,
2790                  "average": 2.0,
2791                  "min": 0,
2792                  "max": 2
2793                }
2794                "#
2795                );
2796            },
2797        );
2798    }
2799
2800    #[cfg(feature = "javascript")]
2801    #[test]
2802    fn javascript_return_and_throw() {
2803        // `throw` is a function exit.
2804        check_metrics::<JavascriptParser>(
2805            "function parseLength(s) {
2806                 if (s === null) throw new Error('null');
2807                 return s.length;
2808             }",
2809            "foo.js",
2810            |metric| {
2811                assert_eq!(metric.nexits.nexits_sum(), 2);
2812                insta::assert_json_snapshot!(
2813                    metric.nexits,
2814                    @r#"
2815                {
2816                  "sum": 2,
2817                  "average": 2.0,
2818                  "min": 0,
2819                  "max": 2
2820                }
2821                "#
2822                );
2823            },
2824        );
2825    }
2826
2827    #[cfg(feature = "mozjs")]
2828    #[test]
2829    fn mozjs_return_and_throw() {
2830        // Same shape as plain JavaScript.
2831        check_metrics::<MozjsParser>(
2832            "function parseLength(s) {
2833                 if (s === null) throw new Error('null');
2834                 return s.length;
2835             }",
2836            "foo.js",
2837            |metric| {
2838                assert_eq!(metric.nexits.nexits_sum(), 2);
2839                insta::assert_json_snapshot!(
2840                    metric.nexits,
2841                    @r#"
2842                {
2843                  "sum": 2,
2844                  "average": 2.0,
2845                  "min": 0,
2846                  "max": 2
2847                }
2848                "#
2849                );
2850            },
2851        );
2852    }
2853
2854    #[cfg(feature = "typescript")]
2855    #[test]
2856    fn typescript_return_and_throw() {
2857        check_metrics::<TypescriptParser>(
2858            "function parseLength(s: string | null): number {
2859                 if (s === null) throw new Error('null');
2860                 return s.length;
2861             }",
2862            "foo.ts",
2863            |metric| {
2864                assert_eq!(metric.nexits.nexits_sum(), 2);
2865                insta::assert_json_snapshot!(
2866                    metric.nexits,
2867                    @r#"
2868                {
2869                  "sum": 2,
2870                  "average": 2.0,
2871                  "min": 0,
2872                  "max": 2
2873                }
2874                "#
2875                );
2876            },
2877        );
2878    }
2879
2880    #[cfg(feature = "typescript")]
2881    #[test]
2882    fn tsx_return_and_throw() {
2883        check_metrics::<TsxParser>(
2884            "function parseLength(s: string | null): number {
2885                 if (s === null) throw new Error('null');
2886                 return s.length;
2887             }",
2888            "foo.tsx",
2889            |metric| {
2890                assert_eq!(metric.nexits.nexits_sum(), 2);
2891                insta::assert_json_snapshot!(
2892                    metric.nexits,
2893                    @r#"
2894                {
2895                  "sum": 2,
2896                  "average": 2.0,
2897                  "min": 0,
2898                  "max": 2
2899                }
2900                "#
2901                );
2902            },
2903        );
2904    }
2905
2906    #[cfg(feature = "java")]
2907    #[test]
2908    fn java_return_and_throw() {
2909        // `throw` exits the method.
2910        check_metrics::<JavaParser>(
2911            "class A {
2912                 int parseLength(String s) {
2913                     if (s == null) throw new NullPointerException();
2914                     return s.length();
2915                 }
2916             }",
2917            "foo.java",
2918            |metric| {
2919                assert_eq!(metric.nexits.nexits_sum(), 2);
2920                insta::assert_json_snapshot!(
2921                    metric.nexits,
2922                    @r#"
2923                {
2924                  "sum": 2,
2925                  "average": 2.0,
2926                  "min": 0,
2927                  "max": 2
2928                }
2929                "#
2930                );
2931            },
2932        );
2933    }
2934
2935    /// #1160 is an *attribution* bug, and `nexits` is where that shows
2936    /// most plainly: the file-level sum never moved, so only a per-space
2937    /// assertion can see it. The compact constructor's `throw` belonged
2938    /// to the enclosing `class R` because the constructor opened no space
2939    /// of its own.
2940    ///
2941    /// Both halves are asserted. `class R`'s own count must be 0 — the
2942    /// aggregate `nexits_sum` is 2 either way, so checking only the new
2943    /// space would pass against the unfixed code as long as the space
2944    /// existed at all.
2945    #[cfg(feature = "java")]
2946    #[test]
2947    fn java_record_compact_constructor_owns_its_exits() {
2948        check_func_space::<JavaParser, _>(
2949            "record R(int a, int b) {
2950                 R {
2951                     if (a < 0) { throw new IllegalArgumentException(); }
2952                 }
2953                 int sum() { return a + b; }
2954             }",
2955            "R.java",
2956            |space| {
2957                assert_eq!(
2958                    space.metrics.nexits.nexits_sum(),
2959                    2,
2960                    "one throw, one return"
2961                );
2962                assert_eq!(
2963                    child_space(&space, "R").metrics.nexits.nexits(),
2964                    0,
2965                    "class R owns neither",
2966                );
2967                assert_eq!(
2968                    function_space(&space, "R").metrics.nexits.nexits(),
2969                    1,
2970                    "the compact constructor owns its throw",
2971                );
2972            },
2973        );
2974    }
2975
2976    #[cfg(feature = "java")]
2977    #[test]
2978    fn java_yield_in_switch_expression() {
2979        // Java-14+ switch-expression `yield` is an explicit exit. Each
2980        // `yield` counts as one, alongside the enclosing `return`.
2981        check_metrics::<JavaParser>(
2982            "class A {
2983                int describe(int n) {
2984                    return switch (n) {
2985                        case 0: yield 100;
2986                        default: yield 200;
2987                    };
2988                }
2989            }",
2990            "foo.java",
2991            |metric| {
2992                assert_eq!(metric.nexits.nexits_sum(), 3);
2993            },
2994        );
2995    }
2996
2997    #[cfg(feature = "groovy")]
2998    #[test]
2999    fn groovy_no_exit() {
3000        // No functions at all — `nexits.sum` is 0.
3001        check_metrics::<GroovyParser>("int a = 42", "foo.groovy", |metric| {
3002            assert_eq!(metric.nexits.nexits_sum(), 0);
3003        });
3004    }
3005
3006    #[cfg(feature = "groovy")]
3007    #[test]
3008    fn groovy_simple_function() {
3009        // One explicit return in a top-level function.
3010        check_metrics::<GroovyParser>(
3011            "int answer() {
3012                return 42
3013            }",
3014            "foo.groovy",
3015            |metric| {
3016                assert_eq!(metric.nexits.nexits_sum(), 1);
3017            },
3018        );
3019    }
3020
3021    #[cfg(feature = "groovy")]
3022    #[test]
3023    fn groovy_return_and_throw() {
3024        check_metrics::<GroovyParser>(
3025            "class A {
3026                int parseLength(String s) {
3027                    if (s == null) throw new NullPointerException()
3028                    return s.length()
3029                }
3030            }",
3031            "foo.groovy",
3032            |metric| {
3033                assert_eq!(metric.nexits.nexits_sum(), 2);
3034            },
3035        );
3036    }
3037
3038    #[cfg(feature = "groovy")]
3039    #[test]
3040    fn groovy_yield_in_switch_expression() {
3041        // Groovy inherits Java-14+ switch-expression `yield`. Each
3042        // explicit `yield` counts as one exit.
3043        check_metrics::<GroovyParser>(
3044            "class A {
3045                int describe(int n) {
3046                    return switch (n) {
3047                        case 0: yield 100;
3048                        default: yield 200;
3049                    }
3050                }
3051            }",
3052            "foo.groovy",
3053            |metric| {
3054                assert_eq!(metric.nexits.nexits_sum(), 3);
3055            },
3056        );
3057    }
3058
3059    #[cfg(feature = "groovy")]
3060    #[test]
3061    fn groovy_implicit_return_not_counted() {
3062        // Groovy allows implicit return of the last expression in a
3063        // closure / function body. The Exit metric only counts
3064        // *explicit* `return` / `yield` / `throw` — consistent with
3065        // Java's docstring.
3066        check_metrics::<GroovyParser>("int identity(int x) { x }", "foo.groovy", |metric| {
3067            assert_eq!(metric.nexits.nexits_sum(), 0);
3068        });
3069    }
3070
3071    #[cfg(feature = "cpp")]
3072    #[test]
3073    fn cpp_return_and_throw() {
3074        // `throw` exits the function.
3075        check_metrics::<CppParser>(
3076            "int parseLength(const char* s) {
3077                 if (s == nullptr) throw std::invalid_argument(\"null\");
3078                 return 0;
3079             }",
3080            "foo.cpp",
3081            |metric| {
3082                assert_eq!(metric.nexits.nexits_sum(), 2);
3083                insta::assert_json_snapshot!(
3084                    metric.nexits,
3085                    @r#"
3086                {
3087                  "sum": 2,
3088                  "average": 2.0,
3089                  "min": 0,
3090                  "max": 2
3091                }
3092                "#
3093                );
3094            },
3095        );
3096    }
3097
3098    /// A coroutine with two `co_yield`s and a `co_return`, which count
3099    /// as three exits, as Python's two `yield`s and a `return` do (#1547).
3100    /// Before #1547 neither statement counted and the sum was 0.
3101    #[cfg(any(feature = "cpp", feature = "mozcpp"))]
3102    const CPP_COROUTINE: &str = "T gen() { co_yield 1; co_yield 2; co_return; }";
3103
3104    // One test per clone, so reverting either impl fails its own test.
3105    #[cfg(feature = "cpp")]
3106    #[test]
3107    fn cpp_co_return_and_co_yield_count_as_exits() {
3108        check_metrics::<CppParser>(CPP_COROUTINE, "foo.cpp", |metric| {
3109            assert_eq!(metric.nexits.nexits_sum(), 3);
3110        });
3111    }
3112
3113    // Mozcpp owns no file extension, so this is its only coverage.
3114    #[cfg(feature = "mozcpp")]
3115    #[test]
3116    fn mozcpp_co_return_and_co_yield_count_as_exits() {
3117        check_metrics::<MozcppParser>(CPP_COROUTINE, "foo.cpp", |metric| {
3118            assert_eq!(metric.nexits.nexits_sum(), 3);
3119        });
3120    }
3121
3122    #[cfg(feature = "python")]
3123    #[test]
3124    fn python_yield_counts_as_exit() {
3125        // Generator suspension via `yield` hands control back to the
3126        // caller — the function does leave its frame, just resumably.
3127        // Mirrors the long-standing C# / PHP behaviour. Two yields plus
3128        // one return == 3 exits inside the one generator function.
3129        check_metrics::<PythonParser>(
3130            "def gen():
3131                 yield 1
3132                 yield 2
3133                 return",
3134            "foo.py",
3135            |metric| {
3136                assert_eq!(metric.nexits.nexits_sum(), 3);
3137                insta::assert_json_snapshot!(
3138                    metric.nexits,
3139                    @r#"
3140                {
3141                  "sum": 3,
3142                  "average": 3.0,
3143                  "min": 0,
3144                  "max": 3
3145                }
3146                "#
3147                );
3148            },
3149        );
3150    }
3151
3152    #[cfg(feature = "javascript")]
3153    #[test]
3154    fn javascript_yield_counts_as_exit() {
3155        // `function*` generator: each `yield` is an exit edge, same as
3156        // Python/C#/PHP. Two yields + one return == 3.
3157        check_metrics::<JavascriptParser>(
3158            "function* gen() {
3159                 yield 1;
3160                 yield 2;
3161                 return;
3162             }",
3163            "foo.js",
3164            |metric| {
3165                assert_eq!(metric.nexits.nexits_sum(), 3);
3166                insta::assert_json_snapshot!(
3167                    metric.nexits,
3168                    @r#"
3169                {
3170                  "sum": 3,
3171                  "average": 3.0,
3172                  "min": 0,
3173                  "max": 3
3174                }
3175                "#
3176                );
3177            },
3178        );
3179    }
3180
3181    #[cfg(feature = "mozjs")]
3182    #[test]
3183    fn mozjs_yield_counts_as_exit() {
3184        // Same shape as plain JavaScript.
3185        check_metrics::<MozjsParser>(
3186            "function* gen() {
3187                 yield 1;
3188                 yield 2;
3189                 return;
3190             }",
3191            "foo.js",
3192            |metric| {
3193                assert_eq!(metric.nexits.nexits_sum(), 3);
3194                insta::assert_json_snapshot!(
3195                    metric.nexits,
3196                    @r#"
3197                {
3198                  "sum": 3,
3199                  "average": 3.0,
3200                  "min": 0,
3201                  "max": 3
3202                }
3203                "#
3204                );
3205            },
3206        );
3207    }
3208
3209    #[cfg(feature = "typescript")]
3210    #[test]
3211    fn typescript_yield_counts_as_exit() {
3212        check_metrics::<TypescriptParser>(
3213            "function* gen(): Generator<number> {
3214                 yield 1;
3215                 yield 2;
3216                 return;
3217             }",
3218            "foo.ts",
3219            |metric| {
3220                assert_eq!(metric.nexits.nexits_sum(), 3);
3221                insta::assert_json_snapshot!(
3222                    metric.nexits,
3223                    @r#"
3224                {
3225                  "sum": 3,
3226                  "average": 3.0,
3227                  "min": 0,
3228                  "max": 3
3229                }
3230                "#
3231                );
3232            },
3233        );
3234    }
3235
3236    #[cfg(feature = "typescript")]
3237    #[test]
3238    fn tsx_yield_counts_as_exit() {
3239        check_metrics::<TsxParser>(
3240            "function* gen(): Generator<number> {
3241                 yield 1;
3242                 yield 2;
3243                 return;
3244             }",
3245            "foo.tsx",
3246            |metric| {
3247                assert_eq!(metric.nexits.nexits_sum(), 3);
3248                insta::assert_json_snapshot!(
3249                    metric.nexits,
3250                    @r#"
3251                {
3252                  "sum": 3,
3253                  "average": 3.0,
3254                  "min": 0,
3255                  "max": 3
3256                }
3257                "#
3258                );
3259            },
3260        );
3261    }
3262
3263    #[cfg(feature = "python")]
3264    #[test]
3265    fn python_yield_forms_count_as_exit() {
3266        // tree-sitter-python emits a single `Python::Yield` node kind for
3267        // every yield form: bare `yield`, `yield value`, and `yield from
3268        // iter`. The match arm therefore covers all three with no extra
3269        // variants needed. Three yield forms == 3 exits.
3270        check_metrics::<PythonParser>(
3271            "def gen():
3272                 yield
3273                 yield 1
3274                 yield from range(3)",
3275            "foo.py",
3276            |metric| {
3277                assert_eq!(metric.nexits.nexits_sum(), 3);
3278                insta::assert_json_snapshot!(
3279                    metric.nexits,
3280                    @r#"
3281                {
3282                  "sum": 3,
3283                  "average": 3.0,
3284                  "min": 0,
3285                  "max": 3
3286                }
3287                "#
3288                );
3289            },
3290        );
3291    }
3292
3293    #[cfg(feature = "javascript")]
3294    #[test]
3295    fn javascript_yield_delegate_counts_as_exit() {
3296        // Delegating yield (`yield*`) parses as the same
3297        // `Javascript::YieldExpression` node as plain `yield`, so the
3298        // existing match arm covers it. Two regular yields + one
3299        // delegate == 3 exits.
3300        check_metrics::<JavascriptParser>(
3301            "function* gen() {
3302                 yield 1;
3303                 yield* other();
3304                 yield 2;
3305             }",
3306            "foo.js",
3307            |metric| {
3308                assert_eq!(metric.nexits.nexits_sum(), 3);
3309                insta::assert_json_snapshot!(
3310                    metric.nexits,
3311                    @r#"
3312                {
3313                  "sum": 3,
3314                  "average": 3.0,
3315                  "min": 0,
3316                  "max": 3
3317                }
3318                "#
3319                );
3320            },
3321        );
3322    }
3323
3324    #[cfg(feature = "mozjs")]
3325    #[test]
3326    fn mozjs_yield_delegate_counts_as_exit() {
3327        check_metrics::<MozjsParser>(
3328            "function* gen() {
3329                 yield 1;
3330                 yield* other();
3331                 yield 2;
3332             }",
3333            "foo.js",
3334            |metric| {
3335                assert_eq!(metric.nexits.nexits_sum(), 3);
3336                insta::assert_json_snapshot!(
3337                    metric.nexits,
3338                    @r#"
3339                {
3340                  "sum": 3,
3341                  "average": 3.0,
3342                  "min": 0,
3343                  "max": 3
3344                }
3345                "#
3346                );
3347            },
3348        );
3349    }
3350
3351    #[cfg(feature = "typescript")]
3352    #[test]
3353    fn typescript_yield_delegate_counts_as_exit() {
3354        check_metrics::<TypescriptParser>(
3355            "function* gen(): Generator<number> {
3356                 yield 1;
3357                 yield* other();
3358                 yield 2;
3359             }",
3360            "foo.ts",
3361            |metric| {
3362                assert_eq!(metric.nexits.nexits_sum(), 3);
3363                insta::assert_json_snapshot!(
3364                    metric.nexits,
3365                    @r#"
3366                {
3367                  "sum": 3,
3368                  "average": 3.0,
3369                  "min": 0,
3370                  "max": 3
3371                }
3372                "#
3373                );
3374            },
3375        );
3376    }
3377
3378    #[cfg(feature = "typescript")]
3379    #[test]
3380    fn tsx_yield_delegate_counts_as_exit() {
3381        check_metrics::<TsxParser>(
3382            "function* gen(): Generator<number> {
3383                 yield 1;
3384                 yield* other();
3385                 yield 2;
3386             }",
3387            "foo.tsx",
3388            |metric| {
3389                assert_eq!(metric.nexits.nexits_sum(), 3);
3390                insta::assert_json_snapshot!(
3391                    metric.nexits,
3392                    @r#"
3393                {
3394                  "sum": 3,
3395                  "average": 3.0,
3396                  "min": 0,
3397                  "max": 3
3398                }
3399                "#
3400                );
3401            },
3402        );
3403    }
3404
3405    /// A handler with no `return` has zero exits (iRules has no `return`
3406    /// keyword node; `return` is a generic command matched by name).
3407    #[cfg(feature = "irules")]
3408    #[test]
3409    fn irules_no_exit() {
3410        check_metrics::<IrulesParser>(
3411            "when HTTP_REQUEST {
3412    set x 1
3413    log local0. $x
3414}
3415",
3416            "foo.irule",
3417            |metric| {
3418                assert_eq!(metric.nexits.nexits_sum(), 0);
3419            },
3420        );
3421    }
3422
3423    /// A `return` command contributes one exit.
3424    #[cfg(feature = "irules")]
3425    #[test]
3426    fn irules_return() {
3427        check_metrics::<IrulesParser>(
3428            "when HTTP_REQUEST {
3429    if { [HTTP::uri] eq \"/\" } {
3430        return
3431    }
3432    log local0. \"served\"
3433}
3434",
3435            "foo.irule",
3436            |metric| {
3437                assert_eq!(metric.nexits.nexits_sum(), 1);
3438            },
3439        );
3440    }
3441
3442    /// A multi-value `return` (`return [list ...]`) is a single command and
3443    /// counts once, not once per returned value.
3444    #[cfg(feature = "irules")]
3445    #[test]
3446    fn irules_multi_value_return_counts_once() {
3447        check_metrics::<IrulesParser>(
3448            "proc pair { a b } {
3449    return [list $a $b]
3450}
3451",
3452            "foo.irule",
3453            |metric| {
3454                assert_eq!(metric.nexits.nexits_sum(), 1);
3455            },
3456        );
3457    }
3458
3459    /// `error` is a plain Tcl builtin that reaches iRules unchanged and
3460    /// parses to the same `command` + name-word shape (#1270),
3461    /// re-derived against the iRules grammar rather than assumed from
3462    /// Tcl's.
3463    #[cfg(feature = "irules")]
3464    #[test]
3465    fn irules_error_is_an_exit() {
3466        check_metrics::<IrulesParser>(
3467            "proc f { x } {
3468    if { $x < 0 } {
3469        error \"negative\"
3470    }
3471    return $x
3472}
3473",
3474            "foo.irule",
3475            |metric| {
3476                // expected: `error` + `return` = 2.
3477                assert_eq!(metric.nexits.nexits_sum(), 2);
3478            },
3479        );
3480    }
3481
3482    /// Tcl 8.6's `throw` is deliberately absent from the iRules exit
3483    /// set — TMOS runs a Tcl 8.4-derived interpreter with no such
3484    /// builtin, so the word can only ever name a user proc — and
3485    /// `error` in argument position is not an exit either.
3486    /// iRules resolves the exit name in its own walker rather than
3487    /// through `tcl_command_name`, so the `::` strip needs pinning on
3488    /// that second path too — the sibling sweep grammar-dispatch.md
3489    /// requires (#1381 review).
3490    #[cfg(feature = "irules")]
3491    #[test]
3492    fn irules_qualified_exits_are_exits() {
3493        check_metrics::<IrulesParser>(
3494            "proc f { x } {
3495    if { $x < 0 } {
3496        ::error \"negative\"
3497    }
3498    ::return $x
3499}
3500",
3501            "foo.irule",
3502            |metric| {
3503                assert_eq!(metric.nexits.nexits_sum(), 2);
3504            },
3505        );
3506    }
3507
3508    /// Control: `ns::return` is a proc in `ns`, not the core command.
3509    #[cfg(feature = "irules")]
3510    #[test]
3511    fn irules_namespaced_return_is_not_an_exit() {
3512        check_metrics::<IrulesParser>(
3513            "proc f { x } {
3514    ns::return $x
3515}
3516",
3517            "foo.irule",
3518            |metric| {
3519                assert_eq!(metric.nexits.nexits_sum(), 0);
3520            },
3521        );
3522    }
3523
3524    #[cfg(feature = "irules")]
3525    #[test]
3526    fn irules_throw_and_argument_position_error_are_not_exits() {
3527        check_metrics::<IrulesParser>(
3528            "proc f { x } {
3529    throw {ARITH DIVZERO} \"boom\"
3530    log local0. error
3531}
3532",
3533            "foo.irule",
3534            |metric| {
3535                assert_eq!(metric.nexits.nexits_sum(), 0);
3536            },
3537        );
3538    }
3539
3540    /// Objective-C method with no `return` and no `@throw` has zero exit
3541    /// points.
3542    #[cfg(feature = "objc")]
3543    #[test]
3544    fn objc_no_exit() {
3545        check_metrics::<ObjcParser>(
3546            "@implementation Foo
3547- (void)bar {
3548    [self doWork];
3549}
3550@end
3551",
3552            "foo.m",
3553            |metric| {
3554                assert_eq!(metric.nexits.nexits_sum(), 0);
3555                insta::assert_json_snapshot!(metric.nexits, @r#"
3556                {
3557                  "sum": 0,
3558                  "average": 0.0,
3559                  "min": 0,
3560                  "max": 0
3561                }
3562                "#);
3563            },
3564        );
3565    }
3566
3567    /// Objective-C exit set is `return_statement` + `@throw`
3568    /// (`throw_statement`): a method with one of each counts 2.
3569    #[cfg(feature = "objc")]
3570    #[test]
3571    fn objc_return_and_throw() {
3572        check_metrics::<ObjcParser>(
3573            "@implementation Foo
3574- (int)bar:(int)x {
3575    if (x < 0) {
3576        @throw [NSException exceptionWithName:@\"e\" reason:@\"r\" userInfo:nil];
3577    }
3578    return x;
3579}
3580@end
3581",
3582            "foo.m",
3583            |metric| {
3584                assert_eq!(metric.nexits.nexits_sum(), 2);
3585                insta::assert_json_snapshot!(metric.nexits, @r#"
3586                {
3587                  "sum": 2,
3588                  "average": 2.0,
3589                  "min": 0,
3590                  "max": 2
3591                }
3592                "#);
3593            },
3594        );
3595    }
3596}