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