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