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