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