Skip to main content

big_code_analysis/metrics/
exit.rs

1// Per-language metric and AST modules deliberately consume the macro-
2// generated tree-sitter token enums via `use crate::*` and `use Foo::*`
3// inside match expressions — explicit imports would list dozens of
4// variants per arm and obscure the per-language token sets that are the
5// point of these files. Allowed at the module level rather than per
6// function so the per-language impl blocks stay readable.
7#![allow(clippy::wildcard_imports, clippy::enum_glob_use)]
8// Metric counts (token, function, branch, argument, etc.) are stored as
9// `usize` and crossed with `f64` averages, ratios, and Halstead scores
10// across the cyclomatic / MI / Halstead computations. The `usize as f64`
11// and `f64 as usize` casts are intentional and snapshot-anchored — every
12// site is bounded by the count it came from. Allowing the lints at the
13// module level keeps the metric arithmetic legible.
14#![allow(
15    clippy::cast_precision_loss,
16    clippy::cast_possible_truncation,
17    clippy::cast_sign_loss
18)]
19
20use serde::Serialize;
21use serde::ser::{SerializeStruct, Serializer};
22use std::fmt;
23
24use crate::checker::Checker;
25use crate::macros::implement_metric_trait;
26use crate::*;
27
28/// The `NExit` metric.
29///
30/// This metric counts the number of possible exit points
31/// from a function/method.
32#[derive(Debug, Clone)]
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 Serialize for Stats {
54    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
55    where
56        S: Serializer,
57    {
58        let mut st = serializer.serialize_struct("nexits", 4)?;
59        st.serialize_field("sum", &self.exit_sum())?;
60        st.serialize_field("average", &self.exit_average())?;
61        st.serialize_field("min", &self.exit_min())?;
62        st.serialize_field("max", &self.exit_max())?;
63        st.end()
64    }
65}
66
67impl fmt::Display for Stats {
68    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
69        write!(
70            f,
71            "sum: {}, average: {} min: {}, max: {}",
72            self.exit_sum(),
73            self.exit_average(),
74            self.exit_min(),
75            self.exit_max()
76        )
77    }
78}
79
80impl Stats {
81    /// Merges a second `NExit` metric into the first one
82    pub fn merge(&mut self, other: &Stats) {
83        self.exit_max = self.exit_max.max(other.exit_max);
84        self.exit_min = self.exit_min.min(other.exit_min);
85        self.exit_sum += other.exit_sum;
86    }
87
88    /// Returns the `NExit` metric value
89    #[must_use]
90    pub fn exit(&self) -> f64 {
91        self.exit as f64
92    }
93    /// Returns the `NExit` metric sum value
94    #[must_use]
95    pub fn exit_sum(&self) -> f64 {
96        self.exit_sum as f64
97    }
98    /// Returns the `NExit` metric minimum value.
99    ///
100    /// Collapses the `usize::MAX` sentinel that `Stats::default()` plants
101    /// into `exit_min` to `0.0`, so a never-observed space
102    /// serializes to a meaningful number rather than `1.8446744e19`.
103    #[must_use]
104    pub fn exit_min(&self) -> f64 {
105        if self.exit_min == usize::MAX {
106            0.0
107        } else {
108            self.exit_min as f64
109        }
110    }
111    /// Returns the `NExit` metric maximum value
112    #[must_use]
113    pub fn exit_max(&self) -> f64 {
114        self.exit_max as f64
115    }
116
117    /// Returns the `NExit` metric average value
118    ///
119    /// This value is computed dividing the `NExit` value
120    /// for the total number of functions/closures in a space.
121    ///
122    /// If there are no functions in a code, its value is `NAN`.
123    #[must_use]
124    pub fn exit_average(&self) -> f64 {
125        self.exit_sum() / self.total_space_functions as f64
126    }
127    #[inline]
128    pub(crate) fn compute_sum(&mut self) {
129        self.exit_sum += self.exit;
130    }
131    #[inline]
132    pub(crate) fn compute_minmax(&mut self) {
133        self.exit_max = self.exit_max.max(self.exit);
134        self.exit_min = self.exit_min.min(self.exit);
135        self.compute_sum();
136    }
137    pub(crate) fn finalize(&mut self, total_space_functions: usize) {
138        self.total_space_functions = total_space_functions;
139    }
140}
141
142#[doc(hidden)]
143/// Per-language computation of the exit-point count.
144pub trait Exit
145where
146    Self: Checker,
147{
148    /// Walk `node` and update `stats` with this metric for the language
149    /// implementing the trait.
150    fn compute<'a>(node: &Node<'a>, code: &'a [u8], stats: &mut Stats);
151}
152
153// Bumps `stats.exit` whenever the current node matches any of the
154// supplied per-language token variants. Mirrors the `js_cognitive!` /
155// `impl_cyclomatic_c_family!` shape used elsewhere in `src/metrics/`.
156macro_rules! impl_exit_match_kinds {
157    ($code:ty, $lang:ident, [$($kind:ident),+ $(,)?]) => {
158        impl Exit for $code {
159            fn compute<'a>(node: &Node<'a>, _code: &'a [u8], stats: &mut Stats) {
160                if matches!(node.kind_id().into(), $($lang::$kind)|+) {
161                    stats.exit += 1;
162                }
163            }
164        }
165    };
166}
167
168// `Python::Yield` is the yield-expression node (kind text "yield"); Python
169// has no dedicated yield-statement variant. Counting it as an exit mirrors
170// `CsharpCode` / `PhpCode`: generator suspension hands control back to the
171// caller, so the function does leave even though it may later resume.
172impl_exit_match_kinds!(PythonCode, Python, [ReturnStatement, RaiseStatement, Yield]);
173// JS-family generators: `yield` / `yield*` parse as `YieldExpression`.
174// Counted for the same reason as Python — see comment above.
175impl_exit_match_kinds!(
176    MozjsCode,
177    Mozjs,
178    [ReturnStatement, ThrowStatement, YieldExpression]
179);
180impl_exit_match_kinds!(
181    JavascriptCode,
182    Javascript,
183    [ReturnStatement, ThrowStatement, YieldExpression]
184);
185impl_exit_match_kinds!(
186    TypescriptCode,
187    Typescript,
188    [ReturnStatement, ThrowStatement, YieldExpression]
189);
190impl_exit_match_kinds!(
191    TsxCode,
192    Tsx,
193    [ReturnStatement, ThrowStatement, YieldExpression]
194);
195impl_exit_match_kinds!(CppCode, Cpp, [ReturnStatement, ThrowStatement]);
196impl_exit_match_kinds!(JavaCode, Java, [ReturnStatement, ThrowStatement]);
197// Groovy's `yield` is the Java-14+ switch-expression yield, identical
198// to Java's. Implicit-return-from-closure is NOT counted as an exit
199// (consistent with Java) — only explicit return / throw / yield count.
200impl_exit_match_kinds!(
201    GroovyCode,
202    Groovy,
203    [ReturnStatement, ThrowStatement, YieldStatement]
204);
205
206impl Exit for RustCode {
207    fn compute<'a>(node: &Node<'a>, _code: &'a [u8], stats: &mut Stats) {
208        // Count only explicit `return` and `?` (TryExpression). The
209        // implicit final-expression path is NOT an exit — peer-language
210        // impls have the same convention. See #243 for the prior bug
211        // that added a spurious +1 for every function with a return
212        // type.
213        if matches!(
214            node.kind_id().into(),
215            Rust::ReturnExpression | Rust::TryExpression
216        ) {
217            stats.exit += 1;
218        }
219    }
220}
221
222impl Exit for CsharpCode {
223    fn compute<'a>(node: &Node<'a>, _code: &'a [u8], stats: &mut Stats) {
224        if matches!(
225            node.kind_id().into(),
226            Csharp::ReturnStatement
227                | Csharp::YieldStatement
228                | Csharp::ThrowStatement
229                | Csharp::ThrowExpression
230        ) {
231            stats.exit += 1;
232        }
233    }
234}
235
236impl Exit for GoCode {
237    fn compute<'a>(node: &Node<'a>, _code: &'a [u8], stats: &mut Stats) {
238        if matches!(node.kind_id().into(), Go::ReturnStatement) {
239            stats.exit += 1;
240        }
241    }
242}
243
244impl Exit for PerlCode {
245    fn compute<'a>(node: &Node<'a>, _code: &'a [u8], stats: &mut Stats) {
246        if node.kind_id() == Perl::ReturnExpression {
247            stats.exit += 1;
248        }
249    }
250}
251
252impl Exit for KotlinCode {
253    fn compute<'a>(node: &Node<'a>, _code: &'a [u8], stats: &mut Stats) {
254        if matches!(
255            node.kind_id().into(),
256            Kotlin::ReturnExpression | Kotlin::ThrowExpression
257        ) {
258            stats.exit += 1;
259        }
260    }
261}
262
263impl Exit for LuaCode {
264    fn compute<'a>(node: &Node<'a>, _code: &'a [u8], stats: &mut Stats) {
265        if node.kind_id() == Lua::ReturnStatement {
266            stats.exit += 1;
267        }
268    }
269}
270
271impl Exit for BashCode {
272    fn compute<'a>(node: &Node<'a>, code: &'a [u8], stats: &mut Stats) {
273        // Bash has no `return_statement` node: `return` and `exit` are
274        // ordinary builtins parsed as `Bash::Command` whose `name` field
275        // points at a `Bash::CommandName`. Identify them by comparing the
276        // command-name text against the literal builtins.
277        if matches!(node.kind_id().into(), Bash::Command)
278            && let Some(name) = node.child_by_field_name("name")
279            && matches!(name.utf8_text(code), Some("return" | "exit"))
280        {
281            stats.exit += 1;
282        }
283    }
284}
285
286impl Exit for TclCode {
287    fn compute<'a>(node: &Node<'a>, code: &'a [u8], stats: &mut Stats) {
288        // Tcl has no return keyword node; `return` is a generic Command whose
289        // name field is a simple_word with text "return".
290        if node.kind_id() == Tcl::Command
291            && let Some(name) = node.child_by_field_name("name")
292            && name.kind_id() == Tcl::SimpleWord
293            && name.utf8_text(code) == Some("return")
294        {
295            stats.exit += 1;
296        }
297    }
298}
299
300impl Exit for PhpCode {
301    // tree-sitter-php 0.24.2's `exit_statement` rule covers `exit` only
302    // (with or without parentheses); `die(...)` is grammar-classified as
303    // a `function_call_expression` and therefore is NOT counted here.
304    // Detecting `die` would require inspecting call-expression callee
305    // text — brittle and likely to false-match user-defined `die`
306    // functions. Modern PHP idiom favors `throw new Exception()` over
307    // `die`, so leaving this asymmetric is acceptable.
308    fn compute<'a>(node: &Node<'a>, _code: &'a [u8], stats: &mut Stats) {
309        if matches!(
310            node.kind_id().into(),
311            Php::ReturnStatement | Php::YieldExpression | Php::ThrowExpression | Php::ExitStatement
312        ) {
313            stats.exit += 1;
314        }
315    }
316}
317
318// Real defaults — no functions to return from. Audited in #188.
319implement_metric_trait!(Exit, PreprocCode, CcommentCode);
320
321impl Exit for RubyCode {
322    // Ruby's `return` is the only dedicated grammar node for an
323    // intra-function exit. `yield` passes control to the block but does
324    // not exit the enclosing method; `raise`/`exit` are ordinary method
325    // calls without grammar nodes. tree-sitter-ruby exposes the
326    // `return_statement` rule under two aliased visible kinds
327    // (`Return`, `Return2`); the `Return3` token is the bare `return`
328    // keyword inside those nodes and is not counted on its own.
329    fn compute<'a>(node: &Node<'a>, _code: &'a [u8], stats: &mut Stats) {
330        if matches!(node.kind_id().into(), Ruby::Return | Ruby::Return2) {
331            stats.exit += 1;
332        }
333    }
334}
335
336impl Exit for ElixirCode {
337    // Elixir has no `return` statement: the last expression in a function
338    // body is the return value. Early-exit happens through `throw`,
339    // `raise`, `reraise`, or `exit`, all of which surface as `Call`
340    // nodes whose target is an `Identifier` whose text spells the
341    // keyword. Mirrors the Bash/Tcl pattern of comparing target text.
342    fn compute<'a>(node: &Node<'a>, code: &'a [u8], stats: &mut Stats) {
343        if node.kind_id() == Elixir::Call
344            && let Some(target) = node.child_by_field_name("target")
345            && target.kind_id() == Elixir::Identifier
346            && matches!(
347                target.utf8_text(code),
348                Some("throw" | "raise" | "reraise" | "exit")
349            )
350        {
351            stats.exit += 1;
352        }
353    }
354}
355
356#[cfg(test)]
357#[allow(
358    clippy::float_cmp,
359    clippy::cast_precision_loss,
360    clippy::cast_possible_truncation,
361    clippy::cast_sign_loss,
362    clippy::similar_names,
363    clippy::doc_markdown,
364    clippy::needless_raw_string_hashes,
365    clippy::too_many_lines
366)]
367mod tests {
368    use crate::tools::check_metrics;
369
370    use super::*;
371
372    /// A `Stats::default()` that never sees an
373    /// observation must not leak the `usize::MAX` sentinel for
374    /// `exit_min`. The getter collapses the sentinel to `0.0` so
375    /// JSON never emits `1.8446744e19`.
376    #[test]
377    fn exit_empty_file_min_is_zero() {
378        let stats = Stats::default();
379        assert_eq!(stats.exit_min(), 0.0);
380    }
381
382    #[test]
383    fn python_no_exit() {
384        check_metrics::<PythonParser>("a = 42", "foo.py", |metric| {
385            // 0 functions
386            insta::assert_json_snapshot!(
387                metric.nexits,
388                @r###"
389                    {
390                      "sum": 0.0,
391                      "average": null,
392                      "min": 0.0,
393                      "max": 0.0
394                    }"###
395            );
396        });
397    }
398
399    #[test]
400    fn rust_no_exit() {
401        check_metrics::<RustParser>("let a = 42;", "foo.rs", |metric| {
402            // 0 functions
403            insta::assert_json_snapshot!(
404                metric.nexits,
405                @r###"
406                    {
407                      "sum": 0.0,
408                      "average": null,
409                      "min": 0.0,
410                      "max": 0.0
411                    }"###
412            );
413        });
414    }
415
416    #[test]
417    fn rust_question_mark() {
418        check_metrics::<RustParser>("let _ = a? + b? + c?;", "foo.rs", |metric| {
419            // 0 functions
420            insta::assert_json_snapshot!(
421                metric.nexits,
422                @r###"
423                    {
424                      "sum": 3.0,
425                      "average": null,
426                      "min": 3.0,
427                      "max": 3.0
428                    }"###
429            );
430        });
431    }
432
433    // Regression for #243: `Exit for RustCode` used to add 1 whenever
434    // a function_item with an explicit `-> T` was visited. Because the
435    // spaces traversal pushes a new State *before* Exit::compute runs
436    // for that function_item, every Rust function with an explicit
437    // return type was getting one extra exit on top of its real
438    // `return` / `?` exits. The fix drops the spurious clause; this
439    // test pins exit == 1 for a function with one explicit return.
440    #[test]
441    fn rust_explicit_return_with_return_type() {
442        check_metrics::<RustParser>("fn foo() -> i32 { return 1; }", "foo.rs", |metric| {
443            // 1 explicit return / 1 space
444            insta::assert_json_snapshot!(
445                metric.nexits,
446                @r###"
447                    {
448                      "sum": 1.0,
449                      "average": 1.0,
450                      "min": 0.0,
451                      "max": 1.0
452                    }"###
453            );
454        });
455    }
456
457    // Regression for #243: an implicit final-expression return must
458    // NOT count as an exit — matching every other language's
459    // convention (Java, C++, Go, etc. don't count implicit returns).
460    #[test]
461    fn rust_implicit_return_not_counted() {
462        check_metrics::<RustParser>("fn foo() -> i32 { 0 }", "foo.rs", |metric| {
463            // 0 explicit exits / 1 space
464            insta::assert_json_snapshot!(
465                metric.nexits,
466                @r###"
467                {
468                  "sum": 0.0,
469                  "average": 0.0,
470                  "min": 0.0,
471                  "max": 0.0
472                }"###
473            );
474        });
475    }
476
477    // Regression for #243: a function with both an explicit return on
478    // one branch and an implicit final expression should count only
479    // the explicit return.
480    #[test]
481    fn rust_mixed_explicit_and_implicit_return() {
482        check_metrics::<RustParser>(
483            "fn foo(x: bool) -> i32 { if x { return 1; } 0 }",
484            "foo.rs",
485            |metric| {
486                // 1 explicit return; the implicit `0` is not an exit
487                insta::assert_json_snapshot!(
488                    metric.nexits,
489                    @r###"
490                    {
491                      "sum": 1.0,
492                      "average": 1.0,
493                      "min": 0.0,
494                      "max": 1.0
495                    }"###
496                );
497            },
498        );
499    }
500
501    // Regression for #243: `?` inside a function body is the only
502    // implicit-exit form that does count, and the function having an
503    // explicit `Result` return type must not double it.
504    #[test]
505    fn rust_question_mark_in_function() {
506        check_metrics::<RustParser>(
507            "fn foo() -> Result<i32, ()> { Ok(do_thing()?) }",
508            "foo.rs",
509            |metric| {
510                // 1 `?` operator, no explicit `return`
511                insta::assert_json_snapshot!(
512                    metric.nexits,
513                    @r###"
514                    {
515                      "sum": 1.0,
516                      "average": 1.0,
517                      "min": 0.0,
518                      "max": 1.0
519                    }"###
520                );
521            },
522        );
523    }
524
525    // Regression for #243: a unit-returning function with no
526    // explicit `return` or `?` must report 0 exits.
527    #[test]
528    fn rust_unit_return_no_exit() {
529        check_metrics::<RustParser>("fn foo() { let _x = 1; }", "foo.rs", |metric| {
530            // 0 exits / 1 space
531            insta::assert_json_snapshot!(
532                metric.nexits,
533                @r###"
534                {
535                  "sum": 0.0,
536                  "average": 0.0,
537                  "min": 0.0,
538                  "max": 0.0
539                }"###
540            );
541        });
542    }
543
544    #[test]
545    fn c_no_exit() {
546        check_metrics::<CppParser>("int a = 42;", "foo.c", |metric| {
547            // 0 functions
548            insta::assert_json_snapshot!(
549                metric.nexits,
550                @r###"
551                    {
552                      "sum": 0.0,
553                      "average": null,
554                      "min": 0.0,
555                      "max": 0.0
556                    }"###
557            );
558        });
559    }
560
561    /// Multiple `return` statements across `if` / `else` branches.  Every
562    /// `Cpp::ReturnStatement` adds +1 — there is no early-out collapse.
563    #[test]
564    fn c_multiple_returns_in_branches() {
565        check_metrics::<CppParser>(
566            "int f(int x) {
567                 if (x < 0) {
568                     return -1;
569                 } else if (x == 0) {
570                     return 0;
571                 } else {
572                     return 1;
573                 }
574             }",
575            "foo.c",
576            |metric| {
577                // 1 function, 3 returns
578                assert_eq!(metric.nexits.exit_sum(), 3.0);
579                assert_eq!(metric.nexits.exit_max(), 3.0);
580                insta::assert_json_snapshot!(
581                    metric.nexits,
582                    @r###"
583                    {
584                      "sum": 3.0,
585                      "average": 3.0,
586                      "min": 0.0,
587                      "max": 3.0
588                    }"###
589                );
590            },
591        );
592    }
593
594    /// `return` statements inside `try` and `catch` blocks both count;
595    /// the impl matches `Cpp::ReturnStatement` regardless of enclosing
596    /// scope.  C++-only: bare C has no `try`/`catch`.
597    #[test]
598    fn cpp_return_in_try_catch() {
599        check_metrics::<CppParser>(
600            "int f(int x) {
601                 try {
602                     if (x == 0) {
603                         return 1;
604                     }
605                     return 2;
606                 } catch (...) {
607                     return -1;
608                 }
609             }",
610            "foo.cpp",
611            |metric| {
612                // 1 function, 3 returns (2 in try, 1 in catch); no
613                // `throw` here, so the return-only path stays at 3.
614                assert_eq!(metric.nexits.exit_sum(), 3.0);
615                assert_eq!(metric.nexits.exit_max(), 3.0);
616                insta::assert_json_snapshot!(
617                    metric.nexits,
618                    @r###"
619                    {
620                      "sum": 3.0,
621                      "average": 3.0,
622                      "min": 0.0,
623                      "max": 3.0
624                    }"###
625                );
626            },
627        );
628    }
629
630    /// Early `return` inside a loop body is counted separately from the
631    /// trailing return — every reachable `return` is an exit.
632    #[test]
633    fn c_early_return_in_loop() {
634        check_metrics::<CppParser>(
635            "int find(int* a, int n, int target) {
636                 for (int i = 0; i < n; ++i) {
637                     if (a[i] == target) {
638                         return i;
639                     }
640                 }
641                 return -1;
642             }",
643            "foo.c",
644            |metric| {
645                // 1 function, 2 returns
646                assert_eq!(metric.nexits.exit_sum(), 2.0);
647                assert_eq!(metric.nexits.exit_max(), 2.0);
648                insta::assert_json_snapshot!(
649                    metric.nexits,
650                    @r###"
651                    {
652                      "sum": 2.0,
653                      "average": 2.0,
654                      "min": 0.0,
655                      "max": 2.0
656                    }"###
657                );
658            },
659        );
660    }
661
662    /// `void` function with no explicit `return` — exit count is 0.
663    /// The implicit fall-through return is intentionally not modelled.
664    #[test]
665    fn c_void_no_explicit_return() {
666        check_metrics::<CppParser>(
667            "void greet(const char* who) {
668                 printf(\"hi %s\\n\", who);
669             }",
670            "foo.c",
671            |metric| {
672                // 1 function with zero ReturnStatement nodes.
673                assert_eq!(metric.nexits.exit_sum(), 0.0);
674                assert_eq!(metric.nexits.exit_max(), 0.0);
675                insta::assert_json_snapshot!(
676                    metric.nexits,
677                    @r###"
678                    {
679                      "sum": 0.0,
680                      "average": 0.0,
681                      "min": 0.0,
682                      "max": 0.0
683                    }"###
684                );
685            },
686        );
687    }
688
689    #[test]
690    fn javascript_no_exit() {
691        check_metrics::<JavascriptParser>("var a = 42;", "foo.js", |metric| {
692            // 0 functions
693            insta::assert_json_snapshot!(
694                metric.nexits,
695                @r###"
696                    {
697                      "sum": 0.0,
698                      "average": null,
699                      "min": 0.0,
700                      "max": 0.0
701                    }"###
702            );
703        });
704    }
705
706    #[test]
707    fn javascript_simple_function() {
708        check_metrics::<JavascriptParser>(
709            "function f(a, b) {
710                 if (a) {
711                     return a;
712                 }
713                 return b;
714             }",
715            "foo.js",
716            |metric| {
717                // 1 function with 2 return statements
718                insta::assert_json_snapshot!(
719                    metric.nexits,
720                    @r###"
721                    {
722                      "sum": 2.0,
723                      "average": 2.0,
724                      "min": 0.0,
725                      "max": 2.0
726                    }"###
727                );
728            },
729        );
730    }
731
732    #[test]
733    fn javascript_nested_functions() {
734        check_metrics::<JavascriptParser>(
735            "function outer() {
736                 function inner() {
737                     return 1;
738                 }
739                 return inner();
740             }",
741            "foo.js",
742            |metric| {
743                // 2 functions, each with 1 return
744                insta::assert_json_snapshot!(
745                    metric.nexits,
746                    @r###"
747                    {
748                      "sum": 2.0,
749                      "average": 1.0,
750                      "min": 0.0,
751                      "max": 1.0
752                    }"###
753                );
754            },
755        );
756    }
757
758    #[test]
759    fn python_simple_function() {
760        check_metrics::<PythonParser>(
761            "def f(a, b):
762                 if a:
763                     return a",
764            "foo.py",
765            |metric| {
766                // 1 function
767                insta::assert_json_snapshot!(
768                    metric.nexits,
769                    @r###"
770                    {
771                      "sum": 1.0,
772                      "average": 1.0,
773                      "min": 0.0,
774                      "max": 1.0
775                    }"###
776                );
777            },
778        );
779    }
780
781    #[test]
782    fn python_more_functions() {
783        check_metrics::<PythonParser>(
784            "def f(a, b):
785                 if a:
786                     return a
787            def f(a, b):
788                 if b:
789                     return b",
790            "foo.py",
791            |metric| {
792                // 2 functions
793                insta::assert_json_snapshot!(
794                    metric.nexits,
795                    @r###"
796                    {
797                      "sum": 2.0,
798                      "average": 1.0,
799                      "min": 0.0,
800                      "max": 1.0
801                    }"###
802                );
803            },
804        );
805    }
806
807    #[test]
808    fn python_nested_functions() {
809        check_metrics::<PythonParser>(
810            "def f(a, b):
811                 def foo(a):
812                     if a:
813                         return 1
814                 bar = lambda a: lambda b: b or True or True
815                 return bar(foo(a))(a)",
816            "foo.py",
817            |metric| {
818                // 2 functions + 2 lambdas = 4
819                insta::assert_json_snapshot!(
820                    metric.nexits,
821                    @r###"
822                    {
823                      "sum": 2.0,
824                      "average": 0.5,
825                      "min": 0.0,
826                      "max": 1.0
827                    }"###
828                );
829            },
830        );
831    }
832
833    #[test]
834    fn java_no_exit() {
835        check_metrics::<JavaParser>("int a = 42;", "foo.java", |metric| {
836            // 0 functions
837            insta::assert_json_snapshot!(
838                metric.nexits,
839                @r###"
840                    {
841                      "sum": 0.0,
842                      "average": null,
843                      "min": 0.0,
844                      "max": 0.0
845                    }"###
846            );
847        });
848    }
849
850    #[test]
851    fn java_simple_function() {
852        check_metrics::<JavaParser>(
853            "class A {
854              public int sum(int x, int y) {
855                return x + y;
856              }
857            }",
858            "foo.java",
859            |metric| {
860                // 1 exit / 1 space
861                insta::assert_json_snapshot!(
862                    metric.nexits,
863                    @r###"
864                    {
865                      "sum": 1.0,
866                      "average": 1.0,
867                      "min": 0.0,
868                      "max": 1.0
869                    }"###
870                );
871            },
872        );
873    }
874
875    #[test]
876    fn go_no_return() {
877        check_metrics::<GoParser>(
878            "package main
879            func f() {
880                x := 1
881                _ = x
882            }",
883            "foo.go",
884            |metric| {
885                // No return_statement → exit_sum = 0.
886                insta::assert_json_snapshot!(
887                    metric.nexits,
888                    @r###"
889                    {
890                      "sum": 0.0,
891                      "average": 0.0,
892                      "min": 0.0,
893                      "max": 0.0
894                    }"###
895                );
896            },
897        );
898    }
899
900    #[test]
901    fn go_single_return() {
902        check_metrics::<GoParser>(
903            "package main
904            func f() int {
905                return 1
906            }",
907            "foo.go",
908            |metric| {
909                insta::assert_json_snapshot!(
910                    metric.nexits,
911                    @r###"
912                    {
913                      "sum": 1.0,
914                      "average": 1.0,
915                      "min": 0.0,
916                      "max": 1.0
917                    }"###
918                );
919            },
920        );
921    }
922
923    #[test]
924    fn go_multiple_returns() {
925        check_metrics::<GoParser>(
926            "package main
927            func f(x int) int {
928                if x > 0 {
929                    return 1
930                }
931                if x < 0 {
932                    return -1
933                }
934                return 0
935            }",
936            "foo.go",
937            |metric| {
938                // 3 distinct return_statements across branches.
939                insta::assert_json_snapshot!(
940                    metric.nexits,
941                    @r###"
942                    {
943                      "sum": 3.0,
944                      "average": 3.0,
945                      "min": 0.0,
946                      "max": 3.0
947                    }"###
948                );
949            },
950        );
951    }
952
953    #[test]
954    fn go_naked_return() {
955        check_metrics::<GoParser>(
956            "package main
957            func f() (x int) {
958                x = 1
959                return
960            }",
961            "foo.go",
962            |metric| {
963                // Bare `return` with named results is still a return_statement.
964                insta::assert_json_snapshot!(
965                    metric.nexits,
966                    @r###"
967                    {
968                      "sum": 1.0,
969                      "average": 1.0,
970                      "min": 0.0,
971                      "max": 1.0
972                    }"###
973                );
974            },
975        );
976    }
977
978    #[test]
979    fn go_multivalue_return() {
980        check_metrics::<GoParser>(
981            "package main
982            func f() (int, error) {
983                return 0, nil
984            }",
985            "foo.go",
986            |metric| {
987                // `return a, b` is one return_statement (Go has no comma operator).
988                insta::assert_json_snapshot!(
989                    metric.nexits,
990                    @r###"
991                    {
992                      "sum": 1.0,
993                      "average": 1.0,
994                      "min": 0.0,
995                      "max": 1.0
996                    }"###
997                );
998            },
999        );
1000    }
1001
1002    #[test]
1003    fn java_split_function() {
1004        check_metrics::<JavaParser>(
1005            "class A {
1006              public int multiply(int x, int y) {
1007                if(x == 0 || y == 0){
1008                    return 0;
1009                }
1010                return x * y;
1011              }
1012            }",
1013            "foo.java",
1014            |metric| {
1015                // 2 exit / space 1
1016                insta::assert_json_snapshot!(
1017                    metric.nexits,
1018                    @r###"
1019                    {
1020                      "sum": 2.0,
1021                      "average": 2.0,
1022                      "min": 0.0,
1023                      "max": 2.0
1024                    }"###
1025                );
1026            },
1027        );
1028    }
1029
1030    #[test]
1031    fn csharp_no_exit() {
1032        check_metrics::<CsharpParser>("int a = 42;", "foo.cs", |metric| {
1033            insta::assert_json_snapshot!(
1034                metric.nexits,
1035                @r###"
1036                {
1037                  "sum": 0.0,
1038                  "average": null,
1039                  "min": 0.0,
1040                  "max": 0.0
1041                }"###
1042            );
1043        });
1044    }
1045
1046    #[test]
1047    fn csharp_simple_function() {
1048        check_metrics::<CsharpParser>(
1049            "class A {
1050              public int Sum(int x, int y) {
1051                return x + y;
1052              }
1053            }",
1054            "foo.cs",
1055            |metric| {
1056                insta::assert_json_snapshot!(
1057                    metric.nexits,
1058                    @r###"
1059                    {
1060                      "sum": 1.0,
1061                      "average": 1.0,
1062                      "min": 0.0,
1063                      "max": 1.0
1064                    }"###
1065                );
1066            },
1067        );
1068    }
1069
1070    #[test]
1071    fn csharp_split_function() {
1072        check_metrics::<CsharpParser>(
1073            "class A {
1074              public int Multiply(int x, int y) {
1075                if (x == 0 || y == 0) {
1076                    return 0;
1077                }
1078                return x * y;
1079              }
1080            }",
1081            "foo.cs",
1082            |metric| {
1083                insta::assert_json_snapshot!(
1084                    metric.nexits,
1085                    @r###"
1086                    {
1087                      "sum": 2.0,
1088                      "average": 2.0,
1089                      "min": 0.0,
1090                      "max": 2.0
1091                    }"###
1092                );
1093            },
1094        );
1095    }
1096
1097    #[test]
1098    fn csharp_yield_and_throw() {
1099        check_metrics::<CsharpParser>(
1100            "class A {
1101              public IEnumerable<int> Gen() {
1102                yield return 1;
1103                yield break;
1104              }
1105              public int Bad(int x) {
1106                if (x < 0) throw new System.Exception();
1107                return x;
1108              }
1109            }",
1110            "foo.cs",
1111            |metric| {
1112                // 2 yields + 1 throw + 1 return = 4 across two methods.
1113                insta::assert_json_snapshot!(
1114                    metric.nexits,
1115                    @r#"
1116                {
1117                  "sum": 4.0,
1118                  "average": 2.0,
1119                  "min": 0.0,
1120                  "max": 2.0
1121                }
1122                "#
1123                );
1124            },
1125        );
1126    }
1127
1128    #[test]
1129    fn perl_no_exit() {
1130        check_metrics::<PerlParser>(
1131            "sub f {
1132                print 'hi';
1133            }",
1134            "foo.pl",
1135            |metric| {
1136                insta::assert_json_snapshot!(
1137                    metric.nexits,
1138                    @r#"
1139                {
1140                  "sum": 0.0,
1141                  "average": 0.0,
1142                  "min": 0.0,
1143                  "max": 0.0
1144                }
1145                "#
1146                );
1147            },
1148        );
1149    }
1150
1151    #[test]
1152    fn perl_no_function_no_exit() {
1153        check_metrics::<PerlParser>("my $x = 1;\nprint $x;\n", "foo.pl", |metric| {
1154            insta::assert_json_snapshot!(metric.nexits, @r#"
1155            {
1156              "sum": 0.0,
1157              "average": null,
1158              "min": 0.0,
1159              "max": 0.0
1160            }
1161            "#);
1162        });
1163    }
1164
1165    #[test]
1166    fn perl_multiple_returns() {
1167        check_metrics::<PerlParser>(
1168            "sub f {
1169                return 1 if $_[0];
1170                return 0;
1171            }",
1172            "foo.pl",
1173            |metric| {
1174                insta::assert_json_snapshot!(
1175                    metric.nexits,
1176                    @r#"
1177                {
1178                  "sum": 2.0,
1179                  "average": 2.0,
1180                  "min": 0.0,
1181                  "max": 2.0
1182                }
1183                 "#
1184                );
1185            },
1186        );
1187    }
1188
1189    #[test]
1190    fn tsx_function_with_returns() {
1191        check_metrics::<TsxParser>(
1192            "function clamp(val: number, min: number, max: number) {
1193                 if (val < min) {
1194                     return min;
1195                 }
1196                 if (val > max) {
1197                     return max;
1198                 }
1199                 return val;
1200             }",
1201            "foo.tsx",
1202            |metric| {
1203                insta::assert_json_snapshot!(
1204                    metric.nexits,
1205                    @r###"
1206                    {
1207                      "sum": 3.0,
1208                      "average": 3.0,
1209                      "min": 0.0,
1210                      "max": 3.0
1211                    }"###
1212                );
1213            },
1214        );
1215    }
1216
1217    #[test]
1218    fn typescript_no_exit() {
1219        check_metrics::<TypescriptParser>("const x: number = 42;", "foo.ts", |metric| {
1220            insta::assert_json_snapshot!(
1221                metric.nexits,
1222                @r###"
1223                    {
1224                      "sum": 0.0,
1225                      "average": null,
1226                      "min": 0.0,
1227                      "max": 0.0
1228                    }"###
1229            );
1230        });
1231    }
1232
1233    #[test]
1234    fn typescript_function_with_returns() {
1235        check_metrics::<TypescriptParser>(
1236            "function safeDivide(a: number, b: number): number | null {
1237                 if (b === 0) {
1238                     return null;
1239                 }
1240                 return a / b;
1241             }",
1242            "foo.ts",
1243            |metric| {
1244                insta::assert_json_snapshot!(
1245                    metric.nexits,
1246                    @r###"
1247                    {
1248                      "sum": 2.0,
1249                      "average": 2.0,
1250                      "min": 0.0,
1251                      "max": 2.0
1252                    }"###
1253                );
1254            },
1255        );
1256    }
1257
1258    #[test]
1259    fn mozjs_no_exit() {
1260        check_metrics::<MozjsParser>("var a = 42;", "foo.js", |metric| {
1261            insta::assert_json_snapshot!(
1262                metric.nexits,
1263                @r###"
1264                    {
1265                      "sum": 0.0,
1266                      "average": null,
1267                      "min": 0.0,
1268                      "max": 0.0
1269                    }"###
1270            );
1271        });
1272    }
1273
1274    #[test]
1275    fn mozjs_function_with_returns() {
1276        check_metrics::<MozjsParser>(
1277            "function f(a, b) {
1278                 if (a) {
1279                     return a;
1280                 }
1281                 return b;
1282             }",
1283            "foo.js",
1284            |metric| {
1285                insta::assert_json_snapshot!(
1286                    metric.nexits,
1287                    @r###"
1288                    {
1289                      "sum": 2.0,
1290                      "average": 2.0,
1291                      "min": 0.0,
1292                      "max": 2.0
1293                    }"###
1294                );
1295            },
1296        );
1297    }
1298
1299    #[test]
1300    fn kotlin_exit_return_and_throw() {
1301        check_metrics::<KotlinParser>(
1302            "fun divide(a: Int, b: Int): Int {
1303                if (b == 0) {
1304                    throw IllegalArgumentException(\"zero\")
1305                }
1306                return a / b
1307            }",
1308            "foo.kt",
1309            |metric| {
1310                insta::assert_json_snapshot!(
1311                    metric.nexits,
1312                    @r###"
1313                    {
1314                      "sum": 2.0,
1315                      "average": 2.0,
1316                      "min": 0.0,
1317                      "max": 2.0
1318                    }
1319                    "###
1320                );
1321            },
1322        );
1323    }
1324
1325    #[test]
1326    fn lua_no_exit() {
1327        check_metrics::<LuaParser>(
1328            "local function f(x)
1329  local y = x + 1
1330end",
1331            "foo.lua",
1332            |metric| {
1333                insta::assert_json_snapshot!(
1334                    metric.nexits,
1335                    @r###"
1336                    {
1337                      "sum": 0.0,
1338                      "average": 0.0,
1339                      "min": 0.0,
1340                      "max": 0.0
1341                    }
1342                    "###
1343                );
1344            },
1345        );
1346    }
1347
1348    #[test]
1349    fn lua_return() {
1350        check_metrics::<LuaParser>(
1351            "local function f(x)
1352  if x > 0 then
1353    return x
1354  end
1355  return 0
1356end",
1357            "foo.lua",
1358            |metric| {
1359                insta::assert_json_snapshot!(
1360                    metric.nexits,
1361                    @r###"
1362                    {
1363                      "sum": 2.0,
1364                      "average": 2.0,
1365                      "min": 0.0,
1366                      "max": 2.0
1367                    }
1368                    "###
1369                );
1370            },
1371        );
1372    }
1373
1374    #[test]
1375    fn bash_no_exit() {
1376        check_metrics::<BashParser>("echo \"no exits\"", "foo.sh", |metric| {
1377            insta::assert_json_snapshot!(
1378                metric.nexits,
1379                @r###"
1380                {
1381                  "sum": 0.0,
1382                  "average": null,
1383                  "min": 0.0,
1384                  "max": 0.0
1385                }"###
1386            );
1387        });
1388    }
1389
1390    #[test]
1391    fn bash_explicit_return() {
1392        check_metrics::<BashParser>(
1393            "f() {
1394                 if [ -z \"$1\" ]; then
1395                     return 1
1396                 fi
1397                 echo ok
1398             }",
1399            "foo.sh",
1400            |metric| {
1401                insta::assert_json_snapshot!(
1402                    metric.nexits,
1403                    @r###"
1404                    {
1405                      "sum": 1.0,
1406                      "average": 1.0,
1407                      "min": 0.0,
1408                      "max": 1.0
1409                    }"###
1410                );
1411            },
1412        );
1413    }
1414
1415    #[test]
1416    fn bash_explicit_exit() {
1417        check_metrics::<BashParser>(
1418            "f() {
1419                 exit 0
1420             }",
1421            "foo.sh",
1422            |metric| {
1423                insta::assert_json_snapshot!(
1424                    metric.nexits,
1425                    @r###"
1426                    {
1427                      "sum": 1.0,
1428                      "average": 1.0,
1429                      "min": 0.0,
1430                      "max": 1.0
1431                    }"###
1432                );
1433            },
1434        );
1435    }
1436
1437    #[test]
1438    fn bash_multiple_exits() {
1439        check_metrics::<BashParser>(
1440            "f() {
1441                 if [ \"$1\" = die ]; then
1442                     exit 1
1443                 fi
1444                 return 0
1445             }",
1446            "foo.sh",
1447            |metric| {
1448                insta::assert_json_snapshot!(
1449                    metric.nexits,
1450                    @r###"
1451                    {
1452                      "sum": 2.0,
1453                      "average": 2.0,
1454                      "min": 0.0,
1455                      "max": 2.0
1456                    }"###
1457                );
1458            },
1459        );
1460    }
1461
1462    #[test]
1463    fn bash_returnish_names_are_not_exits() {
1464        // `returncode=1` is a `variable_assignment`, not a Command. The
1465        // function `returns` is invoked via a Command whose CommandName is
1466        // the literal "returns" — it must NOT be matched as a return/exit
1467        // builtin (whole-token match, no prefix collision).
1468        check_metrics::<BashParser>(
1469            "returncode=1
1470             returns() {
1471                 echo named
1472             }
1473             returns",
1474            "foo.sh",
1475            |metric| {
1476                insta::assert_json_snapshot!(
1477                    metric.nexits,
1478                    @r###"
1479                    {
1480                      "sum": 0.0,
1481                      "average": 0.0,
1482                      "min": 0.0,
1483                      "max": 0.0
1484                    }"###
1485                );
1486            },
1487        );
1488    }
1489
1490    #[test]
1491    fn tcl_no_exit() {
1492        check_metrics::<TclParser>(
1493            "proc f {x} {
1494    puts $x
1495}",
1496            "foo.tcl",
1497            |metric| {
1498                insta::assert_json_snapshot!(
1499                    metric.nexits,
1500                    @r#"
1501                    {
1502                      "sum": 0.0,
1503                      "average": 0.0,
1504                      "min": 0.0,
1505                      "max": 0.0
1506                    }
1507                    "#
1508                );
1509            },
1510        );
1511    }
1512
1513    #[test]
1514    fn tcl_return() {
1515        check_metrics::<TclParser>(
1516            "proc f {x} {
1517    return $x
1518}",
1519            "foo.tcl",
1520            |metric| {
1521                assert_eq!(metric.nexits.exit_sum(), 1.0);
1522                assert_eq!(metric.nexits.exit_max(), 1.0);
1523                insta::assert_json_snapshot!(metric.nexits);
1524            },
1525        );
1526    }
1527
1528    #[test]
1529    fn tcl_multiple_returns() {
1530        check_metrics::<TclParser>(
1531            "proc f {x} {
1532    if {$x > 0} {
1533        return positive
1534    }
1535    return nonpositive
1536}",
1537            "foo.tcl",
1538            |metric| {
1539                assert_eq!(metric.nexits.exit_sum(), 2.0);
1540                assert_eq!(metric.nexits.exit_max(), 2.0);
1541                insta::assert_json_snapshot!(metric.nexits);
1542            },
1543        );
1544    }
1545
1546    #[test]
1547    fn typescript_multiple_returns() {
1548        check_metrics::<TypescriptParser>(
1549            "function classify(n: number): string {
1550             if (n > 0) {
1551                 return 'positive';
1552             } else if (n < 0) {
1553                 return 'negative';
1554             }
1555             return 'zero';
1556         }",
1557            "foo.ts",
1558            |metric| {
1559                assert_eq!(metric.nexits.exit_sum(), 3.0);
1560                assert_eq!(metric.nexits.exit_max(), 3.0);
1561                insta::assert_json_snapshot!(metric.nexits);
1562            },
1563        );
1564    }
1565
1566    #[test]
1567    fn typescript_nested_functions() {
1568        check_metrics::<TypescriptParser>(
1569            "function outer(): number {
1570             function inner(): number {
1571                 return 42;
1572             }
1573             return inner();
1574         }",
1575            "foo.ts",
1576            |metric| {
1577                // outer has 1 return, inner has 1 return → sum=2, max=1
1578                assert_eq!(metric.nexits.exit_sum(), 2.0);
1579                assert_eq!(metric.nexits.exit_max(), 1.0);
1580                insta::assert_json_snapshot!(metric.nexits);
1581            },
1582        );
1583    }
1584
1585    #[test]
1586    fn tsx_no_exit() {
1587        check_metrics::<TsxParser>(
1588            "function f(): void {
1589             console.log('hello');
1590         }",
1591            "foo.tsx",
1592            |metric| {
1593                assert_eq!(metric.nexits.exit_sum(), 0.0);
1594                assert_eq!(metric.nexits.exit_max(), 0.0);
1595                insta::assert_json_snapshot!(metric.nexits);
1596            },
1597        );
1598    }
1599
1600    #[test]
1601    fn tsx_multiple_returns() {
1602        check_metrics::<TsxParser>(
1603            "function classify(n: number): string {
1604             if (n > 0) {
1605                 return 'positive';
1606             } else if (n < 0) {
1607                 return 'negative';
1608             }
1609             return 'zero';
1610         }",
1611            "foo.tsx",
1612            |metric| {
1613                assert_eq!(metric.nexits.exit_sum(), 3.0);
1614                assert_eq!(metric.nexits.exit_max(), 3.0);
1615                insta::assert_json_snapshot!(metric.nexits);
1616            },
1617        );
1618    }
1619
1620    #[test]
1621    fn kotlin_multiple_returns() {
1622        check_metrics::<KotlinParser>(
1623            "fun classify(n: Int): String {
1624             if (n > 0) {
1625                 return \"positive\"
1626             } else if (n < 0) {
1627                 return \"negative\"
1628             }
1629             return \"zero\"
1630         }",
1631            "foo.kt",
1632            |metric| {
1633                assert_eq!(metric.nexits.exit_sum(), 3.0);
1634                assert_eq!(metric.nexits.exit_max(), 3.0);
1635                insta::assert_json_snapshot!(metric.nexits);
1636            },
1637        );
1638    }
1639
1640    #[test]
1641    fn kotlin_no_exit() {
1642        check_metrics::<KotlinParser>(
1643            "fun f(): Unit {
1644             println(\"hello\")
1645         }",
1646            "foo.kt",
1647            |metric| {
1648                assert_eq!(metric.nexits.exit_sum(), 0.0);
1649                assert_eq!(metric.nexits.exit_max(), 0.0);
1650                insta::assert_json_snapshot!(metric.nexits);
1651            },
1652        );
1653    }
1654
1655    #[test]
1656    fn mozjs_nested_functions() {
1657        check_metrics::<MozjsParser>(
1658            "function outer() {
1659             function inner() {
1660                 return 42;
1661             }
1662             return inner();
1663         }",
1664            "foo.js",
1665            |metric| {
1666                // outer has 1 return, inner has 1 return → sum=2, max=1
1667                assert_eq!(metric.nexits.exit_sum(), 2.0);
1668                assert_eq!(metric.nexits.exit_max(), 1.0);
1669                insta::assert_json_snapshot!(metric.nexits);
1670            },
1671        );
1672    }
1673
1674    #[test]
1675    fn php_no_exit() {
1676        check_metrics::<PhpParser>("<?php $a = 42;", "foo.php", |metric| {
1677            insta::assert_json_snapshot!(
1678                metric.nexits,
1679                @r###"
1680                {
1681                  "sum": 0.0,
1682                  "average": null,
1683                  "min": 0.0,
1684                  "max": 0.0
1685                }"###
1686            );
1687        });
1688    }
1689
1690    #[test]
1691    fn php_yield_throw() {
1692        // Generator yields and a throw expression in statement position both
1693        // count as exits.
1694        check_metrics::<PhpParser>(
1695            "<?php
1696            function gen() {
1697                yield 1;
1698                yield 2;
1699                throw new \\Exception('x');
1700            }",
1701            "foo.php",
1702            |metric| {
1703                // 3 exits (2 yields + 1 throw) inside one function space.
1704                insta::assert_json_snapshot!(
1705                    metric.nexits,
1706                    @r###"
1707                    {
1708                      "sum": 3.0,
1709                      "average": 3.0,
1710                      "min": 0.0,
1711                      "max": 3.0
1712                    }"###
1713                );
1714            },
1715        );
1716    }
1717
1718    #[test]
1719    fn php_exit_statement() {
1720        // `exit_statement` covers both `exit;` (bare) and `exit(N);` (with
1721        // optional argument). `die` is NOT in the `exit_statement` rule of
1722        // tree-sitter-php 0.24.2 — `die(...)` parses as a function call —
1723        // so we only count `exit` here.
1724        check_metrics::<PhpParser>(
1725            "<?php
1726            function bail(int $code): void {
1727                if ($code === 1) {
1728                    exit(1);
1729                }
1730                exit;
1731            }",
1732            "foo.php",
1733            |metric| {
1734                // 2 exit_statements inside one function space.
1735                insta::assert_json_snapshot!(
1736                    metric.nexits,
1737                    @r###"
1738                    {
1739                      "sum": 2.0,
1740                      "average": 2.0,
1741                      "min": 0.0,
1742                      "max": 2.0
1743                    }"###
1744                );
1745            },
1746        );
1747    }
1748
1749    #[test]
1750    fn elixir_no_exit() {
1751        // Plain function returning a value has no early-exit calls. The
1752        // `average` is `null` because Elixir's only function space is
1753        // the Unit; there is no per-function aggregation to average
1754        // over.
1755        check_metrics::<ElixirParser>(
1756            "defmodule Foo do\n  def add(a, b) do\n    a + b\n  end\nend\n",
1757            "foo.ex",
1758            |metric| {
1759                assert_eq!(metric.nexits.exit_sum(), 0.0);
1760                insta::assert_json_snapshot!(
1761                    metric.nexits,
1762                    @r###"
1763                {
1764                  "sum": 0.0,
1765                  "average": null,
1766                  "min": 0.0,
1767                  "max": 0.0
1768                }"###
1769                );
1770            },
1771        );
1772    }
1773
1774    #[test]
1775    fn elixir_raise_throw_exit() {
1776        // `raise`/`throw`/`exit` are recognised by inspecting the `target`
1777        // field text of `Call` nodes — there is no dedicated AST kind.
1778        check_metrics::<ElixirParser>(
1779            "defmodule Foo do\n  def bad(x) do\n    raise \"first\"\n    throw(:second)\n    exit(:third)\n  end\nend\n",
1780            "foo.ex",
1781            |metric| {
1782                assert_eq!(metric.nexits.exit_sum(), 3.0);
1783                insta::assert_json_snapshot!(
1784                    metric.nexits,
1785                    @r#"
1786                {
1787                  "sum": 3.0,
1788                  "average": null,
1789                  "min": 0.0,
1790                  "max": 3.0
1791                }
1792                "#
1793                );
1794            },
1795        );
1796    }
1797
1798    #[test]
1799    fn elixir_reraise_counts() {
1800        // `reraise` is the Elixir variant of `raise` that re-throws an
1801        // existing exception while preserving the stacktrace; we count
1802        // it as an exit alongside `raise`.
1803        check_metrics::<ElixirParser>(
1804            "defmodule Foo do\n  def wrap(stack) do\n    reraise(\"oops\", stack)\n  end\nend\n",
1805            "foo.ex",
1806            |metric| {
1807                assert_eq!(metric.nexits.exit_sum(), 1.0);
1808            },
1809        );
1810    }
1811
1812    #[test]
1813    fn elixir_lookalike_call_is_not_exit() {
1814        // Only the exact identifiers `throw`/`raise`/`reraise`/`exit` are
1815        // exits; a user-defined `throw_event` or remote-call must NOT
1816        // count. This guards against future text-match regressions.
1817        check_metrics::<ElixirParser>(
1818            "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",
1819            "foo.ex",
1820            |metric| {
1821                assert_eq!(metric.nexits.exit_sum(), 0.0);
1822            },
1823        );
1824    }
1825
1826    #[test]
1827    fn ruby_no_exit() {
1828        // Function body without any `return` produces zero exits.
1829        check_metrics::<RubyParser>("def foo\n  a = 1\n  a + 1\nend\n", "foo.rb", |metric| {
1830            assert_eq!(metric.nexits.exit_sum(), 0.0);
1831        });
1832    }
1833
1834    #[test]
1835    fn ruby_multiple_returns() {
1836        // Four explicit `return` statements (no modifier sugar) — one
1837        // per branch. Anchors the headline sum.
1838        check_metrics::<RubyParser>(
1839            "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",
1840            "foo.rb",
1841            |metric| {
1842                assert_eq!(metric.nexits.exit_sum(), 4.0);
1843            },
1844        );
1845    }
1846
1847    #[test]
1848    fn ruby_explicit_returns() {
1849        // Each `return` (statement or modifier-wrapped) contributes one
1850        // exit. `yield` is intentionally NOT counted (it does not exit
1851        // the method).
1852        check_metrics::<RubyParser>(
1853            "def foo(x)\n  return 0 if x.nil?\n  yield x\n  return x * 2\nend\n",
1854            "foo.rb",
1855            |metric| {
1856                assert_eq!(metric.nexits.exit_sum(), 2.0);
1857                insta::assert_json_snapshot!(metric.nexits);
1858            },
1859        );
1860    }
1861
1862    #[test]
1863    fn python_return_and_raise() {
1864        // `raise` exits the function (stack unwinds)
1865        // just like `return`. Mirrors the C# / Kotlin / PHP / Elixir
1866        // behaviour. One `raise` + one `return` => 2 exits.
1867        check_metrics::<PythonParser>(
1868            "def parse(s):
1869                 if not s:
1870                     raise ValueError(\"empty\")
1871                 return int(s)",
1872            "foo.py",
1873            |metric| {
1874                assert_eq!(metric.nexits.exit_sum(), 2.0);
1875                insta::assert_json_snapshot!(
1876                    metric.nexits,
1877                    @r###"
1878                {
1879                  "sum": 2.0,
1880                  "average": 2.0,
1881                  "min": 0.0,
1882                  "max": 2.0
1883                }
1884                "###
1885                );
1886            },
1887        );
1888    }
1889
1890    #[test]
1891    fn javascript_return_and_throw() {
1892        // `throw` is a function exit.
1893        check_metrics::<JavascriptParser>(
1894            "function parseLength(s) {
1895                 if (s === null) throw new Error('null');
1896                 return s.length;
1897             }",
1898            "foo.js",
1899            |metric| {
1900                assert_eq!(metric.nexits.exit_sum(), 2.0);
1901                insta::assert_json_snapshot!(
1902                    metric.nexits,
1903                    @r###"
1904                {
1905                  "sum": 2.0,
1906                  "average": 2.0,
1907                  "min": 0.0,
1908                  "max": 2.0
1909                }
1910                "###
1911                );
1912            },
1913        );
1914    }
1915
1916    #[test]
1917    fn mozjs_return_and_throw() {
1918        // Same shape as plain JavaScript.
1919        check_metrics::<MozjsParser>(
1920            "function parseLength(s) {
1921                 if (s === null) throw new Error('null');
1922                 return s.length;
1923             }",
1924            "foo.js",
1925            |metric| {
1926                assert_eq!(metric.nexits.exit_sum(), 2.0);
1927                insta::assert_json_snapshot!(
1928                    metric.nexits,
1929                    @r###"
1930                {
1931                  "sum": 2.0,
1932                  "average": 2.0,
1933                  "min": 0.0,
1934                  "max": 2.0
1935                }
1936                "###
1937                );
1938            },
1939        );
1940    }
1941
1942    #[test]
1943    fn typescript_return_and_throw() {
1944        check_metrics::<TypescriptParser>(
1945            "function parseLength(s: string | null): number {
1946                 if (s === null) throw new Error('null');
1947                 return s.length;
1948             }",
1949            "foo.ts",
1950            |metric| {
1951                assert_eq!(metric.nexits.exit_sum(), 2.0);
1952                insta::assert_json_snapshot!(
1953                    metric.nexits,
1954                    @r###"
1955                {
1956                  "sum": 2.0,
1957                  "average": 2.0,
1958                  "min": 0.0,
1959                  "max": 2.0
1960                }
1961                "###
1962                );
1963            },
1964        );
1965    }
1966
1967    #[test]
1968    fn tsx_return_and_throw() {
1969        check_metrics::<TsxParser>(
1970            "function parseLength(s: string | null): number {
1971                 if (s === null) throw new Error('null');
1972                 return s.length;
1973             }",
1974            "foo.tsx",
1975            |metric| {
1976                assert_eq!(metric.nexits.exit_sum(), 2.0);
1977                insta::assert_json_snapshot!(
1978                    metric.nexits,
1979                    @r###"
1980                {
1981                  "sum": 2.0,
1982                  "average": 2.0,
1983                  "min": 0.0,
1984                  "max": 2.0
1985                }
1986                "###
1987                );
1988            },
1989        );
1990    }
1991
1992    #[test]
1993    fn java_return_and_throw() {
1994        // `throw` exits the method.
1995        check_metrics::<JavaParser>(
1996            "class A {
1997                 int parseLength(String s) {
1998                     if (s == null) throw new NullPointerException();
1999                     return s.length();
2000                 }
2001             }",
2002            "foo.java",
2003            |metric| {
2004                assert_eq!(metric.nexits.exit_sum(), 2.0);
2005                insta::assert_json_snapshot!(
2006                    metric.nexits,
2007                    @r###"
2008                {
2009                  "sum": 2.0,
2010                  "average": 2.0,
2011                  "min": 0.0,
2012                  "max": 2.0
2013                }
2014                "###
2015                );
2016            },
2017        );
2018    }
2019
2020    #[test]
2021    fn groovy_no_exit() {
2022        // No functions at all — `nexits.sum` is 0.
2023        check_metrics::<GroovyParser>("int a = 42", "foo.groovy", |metric| {
2024            assert_eq!(metric.nexits.exit_sum(), 0.0);
2025        });
2026    }
2027
2028    #[test]
2029    fn groovy_simple_function() {
2030        // One explicit return in a top-level function.
2031        check_metrics::<GroovyParser>(
2032            "int answer() {
2033                return 42
2034            }",
2035            "foo.groovy",
2036            |metric| {
2037                assert_eq!(metric.nexits.exit_sum(), 1.0);
2038            },
2039        );
2040    }
2041
2042    #[test]
2043    fn groovy_return_and_throw() {
2044        check_metrics::<GroovyParser>(
2045            "class A {
2046                int parseLength(String s) {
2047                    if (s == null) throw new NullPointerException()
2048                    return s.length()
2049                }
2050            }",
2051            "foo.groovy",
2052            |metric| {
2053                assert_eq!(metric.nexits.exit_sum(), 2.0);
2054            },
2055        );
2056    }
2057
2058    #[test]
2059    fn groovy_yield_in_switch_expression() {
2060        // Groovy inherits Java-14+ switch-expression `yield`. Each
2061        // explicit `yield` counts as one exit.
2062        check_metrics::<GroovyParser>(
2063            "class A {
2064                int describe(int n) {
2065                    return switch (n) {
2066                        case 0: yield 100;
2067                        default: yield 200;
2068                    }
2069                }
2070            }",
2071            "foo.groovy",
2072            |metric| {
2073                assert_eq!(metric.nexits.exit_sum(), 3.0);
2074            },
2075        );
2076    }
2077
2078    #[test]
2079    fn groovy_implicit_return_not_counted() {
2080        // Groovy allows implicit return of the last expression in a
2081        // closure / function body. The Exit metric only counts
2082        // *explicit* `return` / `yield` / `throw` — consistent with
2083        // Java's docstring.
2084        check_metrics::<GroovyParser>("int identity(int x) { x }", "foo.groovy", |metric| {
2085            assert_eq!(metric.nexits.exit_sum(), 0.0);
2086        });
2087    }
2088
2089    #[test]
2090    fn cpp_return_and_throw() {
2091        // `throw` exits the function.
2092        check_metrics::<CppParser>(
2093            "int parseLength(const char* s) {
2094                 if (s == nullptr) throw std::invalid_argument(\"null\");
2095                 return 0;
2096             }",
2097            "foo.cpp",
2098            |metric| {
2099                assert_eq!(metric.nexits.exit_sum(), 2.0);
2100                insta::assert_json_snapshot!(
2101                    metric.nexits,
2102                    @r###"
2103                {
2104                  "sum": 2.0,
2105                  "average": 2.0,
2106                  "min": 0.0,
2107                  "max": 2.0
2108                }
2109                "###
2110                );
2111            },
2112        );
2113    }
2114
2115    #[test]
2116    fn python_yield_counts_as_exit() {
2117        // Generator suspension via `yield` hands control back to the
2118        // caller — the function does leave its frame, just resumably.
2119        // Mirrors the long-standing C# / PHP behaviour. Two yields plus
2120        // one return == 3 exits inside the one generator function.
2121        check_metrics::<PythonParser>(
2122            "def gen():
2123                 yield 1
2124                 yield 2
2125                 return",
2126            "foo.py",
2127            |metric| {
2128                assert_eq!(metric.nexits.exit_sum(), 3.0);
2129                insta::assert_json_snapshot!(
2130                    metric.nexits,
2131                    @r###"
2132                {
2133                  "sum": 3.0,
2134                  "average": 3.0,
2135                  "min": 0.0,
2136                  "max": 3.0
2137                }
2138                "###
2139                );
2140            },
2141        );
2142    }
2143
2144    #[test]
2145    fn javascript_yield_counts_as_exit() {
2146        // `function*` generator: each `yield` is an exit edge, same as
2147        // Python/C#/PHP. Two yields + one return == 3.
2148        check_metrics::<JavascriptParser>(
2149            "function* gen() {
2150                 yield 1;
2151                 yield 2;
2152                 return;
2153             }",
2154            "foo.js",
2155            |metric| {
2156                assert_eq!(metric.nexits.exit_sum(), 3.0);
2157                insta::assert_json_snapshot!(
2158                    metric.nexits,
2159                    @r###"
2160                {
2161                  "sum": 3.0,
2162                  "average": 3.0,
2163                  "min": 0.0,
2164                  "max": 3.0
2165                }
2166                "###
2167                );
2168            },
2169        );
2170    }
2171
2172    #[test]
2173    fn mozjs_yield_counts_as_exit() {
2174        // Same shape as plain JavaScript.
2175        check_metrics::<MozjsParser>(
2176            "function* gen() {
2177                 yield 1;
2178                 yield 2;
2179                 return;
2180             }",
2181            "foo.js",
2182            |metric| {
2183                assert_eq!(metric.nexits.exit_sum(), 3.0);
2184                insta::assert_json_snapshot!(
2185                    metric.nexits,
2186                    @r###"
2187                {
2188                  "sum": 3.0,
2189                  "average": 3.0,
2190                  "min": 0.0,
2191                  "max": 3.0
2192                }
2193                "###
2194                );
2195            },
2196        );
2197    }
2198
2199    #[test]
2200    fn typescript_yield_counts_as_exit() {
2201        check_metrics::<TypescriptParser>(
2202            "function* gen(): Generator<number> {
2203                 yield 1;
2204                 yield 2;
2205                 return;
2206             }",
2207            "foo.ts",
2208            |metric| {
2209                assert_eq!(metric.nexits.exit_sum(), 3.0);
2210                insta::assert_json_snapshot!(
2211                    metric.nexits,
2212                    @r###"
2213                {
2214                  "sum": 3.0,
2215                  "average": 3.0,
2216                  "min": 0.0,
2217                  "max": 3.0
2218                }
2219                "###
2220                );
2221            },
2222        );
2223    }
2224
2225    #[test]
2226    fn tsx_yield_counts_as_exit() {
2227        check_metrics::<TsxParser>(
2228            "function* gen(): Generator<number> {
2229                 yield 1;
2230                 yield 2;
2231                 return;
2232             }",
2233            "foo.tsx",
2234            |metric| {
2235                assert_eq!(metric.nexits.exit_sum(), 3.0);
2236                insta::assert_json_snapshot!(
2237                    metric.nexits,
2238                    @r###"
2239                {
2240                  "sum": 3.0,
2241                  "average": 3.0,
2242                  "min": 0.0,
2243                  "max": 3.0
2244                }
2245                "###
2246                );
2247            },
2248        );
2249    }
2250
2251    #[test]
2252    fn python_yield_forms_count_as_exit() {
2253        // tree-sitter-python emits a single `Python::Yield` node kind for
2254        // every yield form: bare `yield`, `yield value`, and `yield from
2255        // iter`. The match arm therefore covers all three with no extra
2256        // variants needed. Three yield forms == 3 exits.
2257        check_metrics::<PythonParser>(
2258            "def gen():
2259                 yield
2260                 yield 1
2261                 yield from range(3)",
2262            "foo.py",
2263            |metric| {
2264                assert_eq!(metric.nexits.exit_sum(), 3.0);
2265                insta::assert_json_snapshot!(
2266                    metric.nexits,
2267                    @r###"
2268                {
2269                  "sum": 3.0,
2270                  "average": 3.0,
2271                  "min": 0.0,
2272                  "max": 3.0
2273                }
2274                "###
2275                );
2276            },
2277        );
2278    }
2279
2280    #[test]
2281    fn javascript_yield_delegate_counts_as_exit() {
2282        // Delegating yield (`yield*`) parses as the same
2283        // `Javascript::YieldExpression` node as plain `yield`, so the
2284        // existing match arm covers it. Two regular yields + one
2285        // delegate == 3 exits.
2286        check_metrics::<JavascriptParser>(
2287            "function* gen() {
2288                 yield 1;
2289                 yield* other();
2290                 yield 2;
2291             }",
2292            "foo.js",
2293            |metric| {
2294                assert_eq!(metric.nexits.exit_sum(), 3.0);
2295                insta::assert_json_snapshot!(
2296                    metric.nexits,
2297                    @r###"
2298                {
2299                  "sum": 3.0,
2300                  "average": 3.0,
2301                  "min": 0.0,
2302                  "max": 3.0
2303                }
2304                "###
2305                );
2306            },
2307        );
2308    }
2309
2310    #[test]
2311    fn mozjs_yield_delegate_counts_as_exit() {
2312        check_metrics::<MozjsParser>(
2313            "function* gen() {
2314                 yield 1;
2315                 yield* other();
2316                 yield 2;
2317             }",
2318            "foo.js",
2319            |metric| {
2320                assert_eq!(metric.nexits.exit_sum(), 3.0);
2321                insta::assert_json_snapshot!(
2322                    metric.nexits,
2323                    @r###"
2324                {
2325                  "sum": 3.0,
2326                  "average": 3.0,
2327                  "min": 0.0,
2328                  "max": 3.0
2329                }
2330                "###
2331                );
2332            },
2333        );
2334    }
2335
2336    #[test]
2337    fn typescript_yield_delegate_counts_as_exit() {
2338        check_metrics::<TypescriptParser>(
2339            "function* gen(): Generator<number> {
2340                 yield 1;
2341                 yield* other();
2342                 yield 2;
2343             }",
2344            "foo.ts",
2345            |metric| {
2346                assert_eq!(metric.nexits.exit_sum(), 3.0);
2347                insta::assert_json_snapshot!(
2348                    metric.nexits,
2349                    @r###"
2350                {
2351                  "sum": 3.0,
2352                  "average": 3.0,
2353                  "min": 0.0,
2354                  "max": 3.0
2355                }
2356                "###
2357                );
2358            },
2359        );
2360    }
2361
2362    #[test]
2363    fn tsx_yield_delegate_counts_as_exit() {
2364        check_metrics::<TsxParser>(
2365            "function* gen(): Generator<number> {
2366                 yield 1;
2367                 yield* other();
2368                 yield 2;
2369             }",
2370            "foo.tsx",
2371            |metric| {
2372                assert_eq!(metric.nexits.exit_sum(), 3.0);
2373                insta::assert_json_snapshot!(
2374                    metric.nexits,
2375                    @r###"
2376                {
2377                  "sum": 3.0,
2378                  "average": 3.0,
2379                  "min": 0.0,
2380                  "max": 3.0
2381                }
2382                "###
2383                );
2384            },
2385        );
2386    }
2387}