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