Skip to main content

big_code_analysis/metrics/
nexits.rs

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