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}