big_code_analysis/metrics/cyclomatic.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 `Cyclomatic` metric.
27#[derive(Debug, Clone, PartialEq)]
28#[non_exhaustive]
29pub struct Stats {
30 cyclomatic_sum: f64,
31 cyclomatic: f64,
32 /// Number of function/closure spaces in this subtree, used as the
33 /// per-function divisor for the cyclomatic averages.
34 ///
35 /// Seeded to `1` for a [`SpaceKind::Function`][crate::SpaceKind]
36 /// space and `0` otherwise (see [`Stats::note_function_space`]), then
37 /// summed across child spaces in [`Stats::merge`]. This is the
38 /// per-function divisor convention shared with `cognitive`/`exit`/
39 /// `nargs`, sourced independently of whether the `Nom` metric was
40 /// selected (#512).
41 ///
42 /// It counts the function/closure *spaces* — the spaces that each
43 /// contribute a base cyclomatic value to the sum — so it equals
44 /// `nom.total()` wherever every function and closure opens its own
45 /// space (the common case). The known exception is a closure form
46 /// that opens no space, such as a Python `lambda`: `nom` counts it
47 /// but it folds its decisions into the enclosing space, so
48 /// `function_spaces` does not count it as a separate divisor unit.
49 function_spaces: usize,
50 cyclomatic_max: f64,
51 cyclomatic_min: f64,
52 cyclomatic_modified_sum: f64,
53 cyclomatic_modified: f64,
54 cyclomatic_modified_max: f64,
55 cyclomatic_modified_min: f64,
56}
57
58impl Default for Stats {
59 fn default() -> Self {
60 Self {
61 cyclomatic_sum: 0.,
62 cyclomatic: 1.,
63 function_spaces: 0,
64 cyclomatic_max: 0.,
65 cyclomatic_min: f64::MAX,
66 cyclomatic_modified_sum: 0.,
67 cyclomatic_modified: 1.,
68 cyclomatic_modified_max: 0.,
69 cyclomatic_modified_min: f64::MAX,
70 }
71 }
72}
73
74impl fmt::Display for Stats {
75 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
76 write!(
77 f,
78 "sum: {}, average: {}, min: {}, max: {}, \
79 modified_sum: {}, modified_average: {}, modified_min: {}, modified_max: {}",
80 self.cyclomatic_sum(),
81 self.cyclomatic_average(),
82 self.cyclomatic_min(),
83 self.cyclomatic_max(),
84 self.cyclomatic_modified_sum(),
85 self.cyclomatic_modified_average(),
86 self.cyclomatic_modified_min(),
87 self.cyclomatic_modified_max(),
88 )
89 }
90}
91
92impl Stats {
93 /// Merges a second `Cyclomatic` metric into the first one
94 pub fn merge(&mut self, other: &Stats) {
95 self.cyclomatic_max = self.cyclomatic_max.max(other.cyclomatic_max);
96 self.cyclomatic_min = self.cyclomatic_min.min(other.cyclomatic_min);
97 self.cyclomatic_sum += other.cyclomatic_sum;
98 self.function_spaces += other.function_spaces;
99
100 self.cyclomatic_modified_max = self
101 .cyclomatic_modified_max
102 .max(other.cyclomatic_modified_max);
103 self.cyclomatic_modified_min = self
104 .cyclomatic_modified_min
105 .min(other.cyclomatic_modified_min);
106 self.cyclomatic_modified_sum += other.cyclomatic_modified_sum;
107 }
108
109 /// Returns the `Cyclomatic` metric value for the current space.
110 #[must_use]
111 pub fn cyclomatic(&self) -> u64 {
112 self.cyclomatic as u64
113 }
114
115 /// Returns the sum of standard cyclomatic values across all spaces.
116 #[must_use]
117 pub fn cyclomatic_sum(&self) -> u64 {
118 self.cyclomatic_sum as u64
119 }
120
121 /// Returns the average standard cyclomatic complexity.
122 ///
123 /// The divisor is the number of function/closure spaces in the
124 /// subtree (`function_spaces`), guarded with `.max(1)` via the shared
125 /// `average` helper. This is the per-function convention shared with
126 /// `cognitive`/`exit`/`nargs`; before #512 the divisor was the
127 /// per-space count `n`, which also counted classes, structs, and the
128 /// file unit and so reported a different — lower — average.
129 #[must_use]
130 pub fn cyclomatic_average(&self) -> f64 {
131 crate::metrics::average(self.cyclomatic_sum() as f64, self.function_spaces)
132 }
133
134 /// Returns the maximum standard cyclomatic complexity.
135 #[must_use]
136 pub fn cyclomatic_max(&self) -> u64 {
137 self.cyclomatic_max as u64
138 }
139
140 /// Returns the minimum standard cyclomatic complexity.
141 ///
142 /// Collapses the `f64::MAX` sentinel that `Stats::default()` plants
143 /// into `cyclomatic_min` to `0`, so a never-observed space
144 /// serializes to a meaningful number rather than `1.7976931e308`.
145 #[allow(clippy::float_cmp)]
146 #[must_use]
147 pub fn cyclomatic_min(&self) -> u64 {
148 if self.cyclomatic_min == f64::MAX {
149 0
150 } else {
151 self.cyclomatic_min as u64
152 }
153 }
154
155 /// Returns the modified cyclomatic complexity for the current space.
156 ///
157 /// Modified cyclomatic counts each switch/match/when/select container as
158 /// one decision point regardless of how many case arms it contains. All
159 /// other branching constructs are weighted identically to standard CCN.
160 ///
161 /// Edge case: an empty switch (`switch (x) {}`) yields modified = 1
162 /// and standard = 0, so modified can exceed standard for arm-less
163 /// containers. This matches Lizard's `-m` convention, which keys on
164 /// the switch keyword rather than the presence of arms.
165 #[must_use]
166 pub fn cyclomatic_modified(&self) -> u64 {
167 self.cyclomatic_modified as u64
168 }
169
170 /// Returns the sum of modified cyclomatic values across all spaces.
171 #[must_use]
172 pub fn cyclomatic_modified_sum(&self) -> u64 {
173 self.cyclomatic_modified_sum as u64
174 }
175
176 /// Returns the average modified cyclomatic complexity.
177 ///
178 /// Uses the same per-function divisor (`function_spaces`, guarded by
179 /// the shared `average` helper) as [`Stats::cyclomatic_average`].
180 #[must_use]
181 pub fn cyclomatic_modified_average(&self) -> f64 {
182 crate::metrics::average(self.cyclomatic_modified_sum() as f64, self.function_spaces)
183 }
184
185 /// Returns the maximum modified cyclomatic complexity.
186 #[must_use]
187 pub fn cyclomatic_modified_max(&self) -> u64 {
188 self.cyclomatic_modified_max as u64
189 }
190
191 /// Returns the minimum modified cyclomatic complexity.
192 ///
193 /// Same `f64::MAX` sentinel collapse as `cyclomatic_min`.
194 #[allow(clippy::float_cmp)]
195 #[must_use]
196 pub fn cyclomatic_modified_min(&self) -> u64 {
197 if self.cyclomatic_modified_min == f64::MAX {
198 0
199 } else {
200 self.cyclomatic_modified_min as u64
201 }
202 }
203
204 /// Marks this space as a function/closure space, seeding the
205 /// per-function divisor (`function_spaces`) with `1`.
206 ///
207 /// Called once at space construction for every
208 /// [`SpaceKind::Function`][crate::SpaceKind] space; non-function
209 /// spaces leave the seed at its `0` default. [`Stats::merge`] then
210 /// sums the seeds so each space's `function_spaces` reflects the
211 /// function/closure count of its whole subtree — independently of
212 /// the `Nom` metric (#512).
213 #[inline]
214 pub(crate) fn note_function_space(&mut self) {
215 self.function_spaces = 1;
216 }
217
218 #[inline]
219 pub(crate) fn compute_sum(&mut self) {
220 self.cyclomatic_sum += self.cyclomatic;
221 self.cyclomatic_modified_sum += self.cyclomatic_modified;
222 }
223
224 #[inline]
225 pub(crate) fn compute_minmax(&mut self) {
226 self.cyclomatic_max = self.cyclomatic_max.max(self.cyclomatic);
227 self.cyclomatic_min = self.cyclomatic_min.min(self.cyclomatic);
228 self.cyclomatic_modified_max = self.cyclomatic_modified_max.max(self.cyclomatic_modified);
229 self.cyclomatic_modified_min = self.cyclomatic_modified_min.min(self.cyclomatic_modified);
230 self.compute_sum();
231 }
232}
233
234#[doc(hidden)]
235/// Per-language computation of cyclomatic complexity.
236pub(crate) trait Cyclomatic
237where
238 Self: Checker,
239{
240 /// Walk `node` and update `stats` with this metric for the language
241 /// implementing the trait.
242 ///
243 /// `code` is the source bytes the node spans, so that languages
244 /// whose branching constructs surface as untyped `Call` nodes
245 /// (Elixir's `if`/`unless`/`for`/`while`/`with`/`case`/`cond`,
246 /// for example) can identify them by inspecting the call target's
247 /// text. Most languages discard the parameter with `_`.
248 fn compute<'a>(node: &Node<'a>, code: &'a [u8], stats: &mut Stats);
249
250 /// Like [`Cyclomatic::compute`], but honors per-traversal options.
251 ///
252 /// `count_try` toggles whether Rust's `?` operator (the
253 /// `try_expression` grammar node) contributes to cyclomatic
254 /// complexity. The default body ignores `count_try` and delegates
255 /// to [`Cyclomatic::compute`], so every language whose grammar has
256 /// no `try_expression` node keeps its existing behaviour with no
257 /// per-language edit. Only [`RustCode`] overrides this to act on
258 /// the flag (#409).
259 #[inline]
260 fn compute_with_options<'a>(
261 node: &Node<'a>,
262 code: &'a [u8],
263 stats: &mut Stats,
264 count_try: bool,
265 ) {
266 let _ = count_try;
267 Self::compute(node, code, stats);
268 }
269}
270
271/// C-family cyclomatic: `Case` adds standard, `SwitchStatement` adds
272/// modified, and the shared branching kinds add both. The ternary token
273/// name varies (`TernaryExpression` for JS-family, `ConditionalExpression`
274/// for Cpp), so it's a parameter. The short-circuit operator list is
275/// also a parameter because JS-family languages include nullish
276/// coalescing (`??`, token `QMARKQMARK`) and the three compound short-
277/// circuit assignment forms `&&=` (`AMPAMPEQ`), `||=` (`PIPEPIPEEQ`),
278/// `??=` (`QMARKQMARKEQ`) on top of `&&` and `||`, while C++ has only
279/// `&&` and `||` (issues #226, #231, #248).
280///
281/// **`If` / `For` / `While` are keyword tokens in the per-language
282/// enums (e.g. `Cpp::While == "while"`), not statement nodes.** The
283/// `while` token therefore fires once inside both `WhileStatement` AND
284/// `DoStatement` (the `while` keyword of `do { … } while (…)`), and
285/// the `for` token fires once inside `ForStatement`, C++
286/// `ForRangeLoop`, Java `EnhancedForStatement`, and any other
287/// grammar-specific loop form that spells the keyword `for`. So
288/// adding the statement nodes themselves would double-count those
289/// loops — see issue #284 for the false-positive analysis. The
290/// regression tests `cpp_do_statement_counts_in_cyclomatic`,
291/// `cpp_for_range_loop_counts_in_cyclomatic`,
292/// `java_do_statement_counts_in_cyclomatic`, and
293/// `java_enhanced_for_statement_counts_in_cyclomatic` pin the
294/// correct keyword-driven counts.
295macro_rules! impl_cyclomatic_c_family {
296 ($code:ty, $lang:ident, $ternary:ident, [$($short_circuit:ident),+ $(,)?]) => {
297 impl Cyclomatic for $code {
298 fn compute<'a>(node: &Node<'a>, _code: &'a [u8], stats: &mut Stats) {
299 use $lang::*;
300 match node.kind_id().into() {
301 Case => stats.cyclomatic += 1.,
302 SwitchStatement => stats.cyclomatic_modified += 1.,
303 If | For | While | Catch | $ternary $(| $short_circuit)+ => {
304 stats.cyclomatic += 1.;
305 stats.cyclomatic_modified += 1.;
306 }
307 _ => {}
308 }
309 }
310 }
311 };
312}
313
314// JS-family: include nullish coalescing (`??`) and the three compound
315// short-circuit assignments `&&=`, `||=`, `??=` as short-circuit
316// decisions in addition to `&&` and `||` (issues #226, #231, #248).
317// Each `op=` is semantically `x = x op y` — one short-circuit decision
318// edge, same as the bare operator. Cognitive parity comes from #236.
319//
320// Optional chaining `?.` is also short-circuit (it skips the rest of
321// the chain when the LHS is nullish) and adds one decision point per
322// occurrence (issue #281). The token varies across grammars:
323// JS/MozJS expose only `OptionalChain` (which IS the `?.` token in
324// those grammars), while TS/TSX expose both an `optional_chain`
325// wrapper and a child `?.` token (`QMARKDOT`); counting `QMARKDOT`
326// matches every textual `?.` exactly once in TS/TSX.
327macro_rules! impl_cyclomatic_js_family {
328 ($code:ty, $lang:ident, $opt_chain:ident) => {
329 impl_cyclomatic_c_family!(
330 $code,
331 $lang,
332 TernaryExpression,
333 [
334 AMPAMP,
335 PIPEPIPE,
336 QMARKQMARK,
337 AMPAMPEQ,
338 PIPEPIPEEQ,
339 QMARKQMARKEQ,
340 $opt_chain
341 ]
342 );
343 };
344}
345
346// Java and Groovy share the same decision-kind set for cyclomatic
347// complexity; Groovy adds `Assert` as an extra branch (its `assert`
348// keyword is a runtime check that branches on its condition,
349// matching Sonar's standard-CCN treatment). `impl_cyclomatic_java_like!`
350// emits the same match body against each enum, with an
351// `[$($extra:ident),*]` list for any language-specific decision kinds
352// (issue #300; mirrors `impl_npm_java_like!` / `impl_npa_java_like!`).
353//
354// Why a dedicated macro instead of reusing `impl_cyclomatic_c_family!`:
355// the C-family macro uses `SwitchStatement` (the wrapping node) as the
356// modified-CCN container marker, whereas Java/Groovy use the `Switch`
357// keyword token — which fires exactly once per switch (both classic
358// switch statements and Java 14+ switch expressions). Counting the
359// keyword keeps the modified-CCN tally aligned with the standard-CCN
360// `Case` arms.
361//
362// Keyword-vs-statement (issue #284): `If` / `For` / `While` here are
363// the *keyword* tokens (`Java::While == "while"`, etc.), not the
364// statement nodes. The `while` keyword therefore fires inside both
365// `WhileStatement` and `DoStatement`, and the `for` keyword fires
366// inside both `ForStatement` and `EnhancedForStatement`. The
367// grammar-specific loop forms are already counted via their inner
368// keyword tokens; listing the statement nodes here would
369// double-count. The regression tests
370// `java_do_statement_counts_in_cyclomatic`,
371// `java_enhanced_for_statement_counts_in_cyclomatic`,
372// `groovy_do_statement_counts_in_cyclomatic`, and
373// `groovy_enhanced_for_statement_counts_in_cyclomatic` pin the
374// correct keyword-driven counts.
375//
376// Groovy note: under the pinned dekobon grammar (root Cargo.toml),
377// Elvis `?:` and the safe-navigation operators `?.` / `??.` all parse
378// cleanly to dedicated nodes with real lexer tokens, so they are
379// counted as branches via the GroovyCode extra-token list below (see
380// the per-call rationale at that invocation). This differs from
381// amaanq's grammar, which emitted ERROR nodes for those constructs.
382macro_rules! impl_cyclomatic_java_like {
383 ($code:ty, $lang:ident, [$($extra:ident),* $(,)?]) => {
384 impl Cyclomatic for $code {
385 fn compute<'a>(node: &Node<'a>, _code: &'a [u8], stats: &mut Stats) {
386 use $lang::*;
387
388 match node.kind_id().into() {
389 Case => {
390 stats.cyclomatic += 1.;
391 }
392 Switch => {
393 stats.cyclomatic_modified += 1.;
394 }
395 If | For | While | Catch | TernaryExpression | AMPAMP | PIPEPIPE
396 $(| $extra)* => {
397 stats.cyclomatic += 1.;
398 stats.cyclomatic_modified += 1.;
399 }
400 _ => {}
401 }
402 }
403 }
404 };
405}
406
407// Real defaults — no executable branches. Audited in #188.
408implement_metric_trait!(Cyclomatic, PreprocCode, CcommentCode);
409
410/// Detects C# `switch_expression_arm`s whose pattern is a bare discard
411/// (`_` or `var _`) and which carry no `when` guard — the analogue of
412/// the C-family `default:` arm. Such arms must NOT contribute to
413/// standard CCN, mirroring Rust's `_ =>` and Java/C#'s `default:`
414/// treatment (lesson 11 / parity family 5). A guarded discard
415/// (`_ when g => …`) still counts because the guard introduces a
416/// non-trivial decision, matching Rust's `_ if g` rule.
417pub(crate) fn csharp_switch_expression_arm_is_bare_discard(node: &Node) -> bool {
418 use Csharp::*;
419
420 /// Classification of a `switch_expression_arm`'s pattern child.
421 /// `BareDiscard` means `_` or `var _` (the C# analogue of
422 /// `default:`); any concrete type test, constant, or composite
423 /// pattern is `NotDiscard` and still contributes to standard CCN.
424 enum PatternKind {
425 BareDiscard,
426 NotDiscard,
427 }
428
429 fn classify_pattern(child: &Node) -> PatternKind {
430 match child.kind_id().into() {
431 // `pattern` is a supertype: tree-sitter flattens it to the
432 // concrete subtype in the parse tree, so a bare `_` arm
433 // surfaces as a direct `discard` child.
434 Discard => PatternKind::BareDiscard,
435 // `var _` parses as a `declaration_pattern` with children
436 // `implicit_type` (`var`) and `discard` (`_`) rather than
437 // as a `var_pattern` — tree-sitter-c-sharp treats `var` as
438 // an implicit type designator. A `declaration_pattern`
439 // whose only named children are `implicit_type` and
440 // `discard` is therefore semantically the bare discard.
441 // A non-implicit type (`int _`) is NOT excluded — the
442 // type test is still a non-trivial decision.
443 DeclarationPattern => {
444 let mut saw_discard = false;
445 let mut saw_implicit_type = false;
446 for sub in child.children().filter(Node::is_named) {
447 match sub.kind_id().into() {
448 Discard => saw_discard = true,
449 ImplicitType => saw_implicit_type = true,
450 _ => return PatternKind::NotDiscard,
451 }
452 }
453 if saw_discard && saw_implicit_type {
454 PatternKind::BareDiscard
455 } else {
456 PatternKind::NotDiscard
457 }
458 }
459 _ => PatternKind::NotDiscard,
460 }
461 }
462
463 let mut named = node.children().filter(Node::is_named);
464 let Some(pattern) = named.next() else {
465 return false;
466 };
467 let PatternKind::BareDiscard = classify_pattern(&pattern) else {
468 return false;
469 };
470 // A guarded discard (`_ when g => …`) still counts because the
471 // guard introduces a non-trivial decision, matching Rust's
472 // `_ if g` rule.
473 !named.any(|c| c.kind_id() == WhenClause)
474}
475
476/// Detects Kotlin `when_entry` nodes that are `else -> …` arms — the
477/// analogue of the C-family `default:` arm. tree-sitter-kotlin-ng
478/// attaches a `condition` field to every case-style entry; the `else`
479/// arm has no `condition` field (only an anonymous `else` keyword
480/// child). Such arms must NOT contribute to standard CCN.
481pub(crate) fn kotlin_when_entry_is_else(node: &Node) -> bool {
482 node.child_by_field_name("condition").is_none()
483}
484
485/// Detects Bash `*)` catch-all arms inside `case … esac`. Returns
486/// `true` when the case_item has exactly one `value` field whose
487/// source text is the literal `*`. Multi-value patterns (`a|b`,
488/// `*|b`) are NOT bare and still count as decisions.
489pub(crate) fn bash_case_item_is_bare_wildcard(node: &Node, code: &[u8]) -> bool {
490 // tree-sitter-bash attaches the `value` field to each alternation
491 // in the case pattern (`a|b)` produces two `value` children).
492 // Walk via a single `TreeCursor`: `field_name()` exposes the field
493 // for the current position and `goto_next_sibling()` is O(1), so
494 // total cost is linear in child count — avoiding the per-call
495 // O(i) `Node::child(i)` access that an index-based loop would
496 // pay on every iteration.
497 let mut cursor = node.as_tree_sitter().walk();
498 if !cursor.goto_first_child() {
499 return false;
500 }
501 let mut value_count = 0usize;
502 let mut sole_value_is_star = false;
503 loop {
504 if cursor.field_name() == Some("value") {
505 value_count += 1;
506 if value_count > 1 {
507 return false;
508 }
509 sole_value_is_star = cursor.node().utf8_text(code).is_ok_and(|s| s.trim() == "*");
510 }
511 if !cursor.goto_next_sibling() {
512 break;
513 }
514 }
515 value_count == 1 && sole_value_is_star
516}
517
518// Per-language `Cyclomatic` impls live in sibling modules. The `mod`
519// declarations sit after the local `macro_rules!` so textual macro
520// scoping reaches the child files (mirrors `getter.rs` and
521// `metrics::abc`).
522mod bash;
523mod c;
524mod cpp;
525mod csharp;
526mod elixir;
527mod go;
528mod groovy;
529mod irules;
530mod java;
531mod javascript;
532mod kotlin;
533mod lua;
534mod mozcpp;
535mod mozjs;
536mod objc;
537mod perl;
538mod php;
539mod python;
540mod ruby;
541mod rust;
542mod tcl;
543mod tsx;
544mod typescript;
545
546#[cfg(test)]
547#[allow(
548 clippy::float_cmp,
549 clippy::cast_precision_loss,
550 clippy::cast_possible_truncation,
551 clippy::cast_sign_loss,
552 clippy::similar_names,
553 clippy::doc_markdown,
554 clippy::needless_raw_string_hashes,
555 clippy::too_many_lines
556)]
557mod tests {
558 use crate::tools::check_metrics;
559
560 use super::*;
561
562 /// A `Stats::default()` that never sees an
563 /// observation must not leak the `f64::MAX` sentinel for
564 /// `cyclomatic_min` or `cyclomatic_modified_min`. Both getters
565 /// collapse the sentinel to `0.0` so JSON never emits
566 /// `1.7976931e308`.
567 #[test]
568 fn cyclomatic_empty_file_min_is_zero() {
569 let stats = Stats::default();
570 assert_eq!(stats.cyclomatic_min(), 0);
571 assert_eq!(stats.cyclomatic_modified_min(), 0);
572 }
573
574 /// A `Stats::default()` with no function spaces and an unguarded
575 /// divisor would divide by zero. The shared `average` helper guards
576 /// the divisor with `.max(1)`, so a function-less aggregate yields a
577 /// finite `0.0` rather than `NaN` (#512 — the guard `cognitive`/
578 /// `exit`/`nargs` already had, now applied to cyclomatic too).
579 #[test]
580 fn cyclomatic_no_function_spaces_average_is_finite() {
581 let stats = Stats::default();
582 assert_eq!(stats.cyclomatic_average(), 0.0);
583 assert_eq!(stats.cyclomatic_modified_average(), 0.0);
584 }
585
586 /// #512: the cyclomatic average divisor is now the per-function count
587 /// (`function_spaces`), reconciled with the `cognitive`/`exit`/`nargs`
588 /// convention, *not* the per-space count `n` it used before. For a
589 /// file with one class holding two methods the spaces are
590 /// `{unit, class, method, method}` (4) but only the two methods are
591 /// functions, so the divisor is 2.
592 ///
593 /// Here every function/closure opens its own space, so
594 /// `function_spaces == nom.total()` and
595 /// `cyclomatic_average == cyclomatic_sum / nom.total()` — the same
596 /// denominator `cognitive_average` divides by. (That equality can
597 /// break for closure forms that open no space, e.g. Python lambdas —
598 /// see `cyclomatic_python_lambda_divisor_excludes_spaceless_closure`.)
599 /// Before #512 the divisor was 4 (every space, base 1 each) and the
600 /// averages were two-thirds of these values (`6 / 4 == 1.5`).
601 #[test]
602 fn cyclomatic_average_is_per_function_512() {
603 check_metrics::<CsharpParser>(
604 "class A {
605 int f(int x) { return x > 0 ? 1 : 2; }
606 int g(int x) { return x > 0 ? 1 : 2; }
607 }",
608 "foo.cs",
609 |metric| {
610 // Sum is over every space's base 1 plus its decisions:
611 // unit(1) + class(1) + f(1 + ternary 1) + g(1 + ternary 1)
612 // = 6.
613 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 6);
614 // Divisor is the two function spaces, not the four total
615 // spaces: 6 / 2 = 3.0 (was 6 / 4 = 1.5 before #512).
616 assert_eq!(metric.cyclomatic.cyclomatic_average(), 3.0);
617 assert_eq!(metric.cyclomatic.cyclomatic_modified_average(), 3.0);
618 // Reconciliation invariant: cyclomatic divides by the same
619 // function/closure count cognitive does.
620 assert_eq!(
621 metric.cyclomatic.cyclomatic_average(),
622 metric.cyclomatic.cyclomatic_sum() as f64 / metric.nom.total() as f64
623 );
624 assert_eq!(metric.nom.total(), 2);
625 },
626 );
627 }
628
629 /// #512: the per-function divisor is sourced from the space kind, not
630 /// from the `Nom` metric, so selecting `cyclomatic` *alone* (which
631 /// does not pull `Nom` in via the metric-selection dependency graph)
632 /// still divides by the function count. This guards the load-bearing
633 /// "one selected metric emits exactly that metric" contract: coupling
634 /// cyclomatic to `nom` to obtain the divisor would have leaked a
635 /// `nom` block into a cyclomatic-only selection.
636 ///
637 /// `nom.total()` is `0.0` here (Nom was never computed) yet the
638 /// average is still the correct per-function `6 / 2 == 3.0` — proof
639 /// the divisor does not read `nom`.
640 #[test]
641 fn cyclomatic_average_per_function_without_nom_512() {
642 let space = crate::analyze(
643 crate::Source::new(
644 crate::LANG::Csharp,
645 b"class A {\n int f(int x) { return x > 0 ? 1 : 2; }\n int g(int x) { return x > 0 ? 1 : 2; }\n}",
646 )
647 .with_name(Some("foo.cs".to_owned())),
648 crate::MetricsOptions::default().with_only(&[crate::Metric::Cyclomatic]),
649 )
650 .expect("analyze must succeed on a well-formed C# fixture");
651
652 let c = &space.metrics.cyclomatic;
653 assert_eq!(c.cyclomatic_sum(), 6);
654 assert_eq!(c.cyclomatic_average(), 3.0);
655 assert_eq!(c.cyclomatic_modified_average(), 3.0);
656 // Nom was not selected, so its count stays at the zero default —
657 // the cyclomatic divisor must not depend on it.
658 assert_eq!(space.metrics.nom.total(), 0);
659 }
660
661 /// #512 edge case: a Python `lambda` is counted by `nom` (as a
662 /// closure) but opens **no** function space — it folds its decisions
663 /// into the enclosing space. So `function_spaces` counts only the
664 /// spaces that actually carry a cyclomatic value (here the single
665 /// `def`), and the cyclomatic divisor is `1`, not `nom.total()`'s `2`.
666 ///
667 /// This documents that the per-function reconciliation with
668 /// `cognitive` is exact only where every function/closure opens its
669 /// own space; for spaceless closures `function_spaces` is the divisor
670 /// that matches the spaces contributing to `cyclomatic_sum`. The
671 /// behaviour is intentional, not a bug — pinning it so a future change
672 /// to lambda space-handling is a deliberate, visible decision.
673 #[test]
674 fn cyclomatic_python_lambda_divisor_excludes_spaceless_closure() {
675 check_metrics::<PythonParser>(
676 "def f(x):\n return x if x > 0 else -x\ng = lambda y: y if y else 0\n",
677 "p.py",
678 |metric| {
679 // sum: unit(1 + lambda's ternary 1) + f(1 + ternary 1) = 4.
680 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
681 // nom counts the lambda as a closure, so total == 2 …
682 assert_eq!(metric.nom.total(), 2);
683 // … but only the `def` opens a function space, so the
684 // cyclomatic divisor is 1: 4 / 1 = 4.0, which deliberately
685 // differs from cyclomatic_sum / nom.total() (4 / 2 = 2.0).
686 assert_eq!(metric.cyclomatic.cyclomatic_average(), 4.0);
687 },
688 );
689 }
690
691 /// A plain `if/else` must not be credited
692 /// as a loop-`else`. The `Else` arm of `impl Cyclomatic for
693 /// PythonCode` previously fired for every `else_clause` because
694 /// the old `has_ancestors` helper only verified the second
695 /// predicate; the rewritten `parent_grandparent_match` requires
696 /// the grandparent to be `for/while/try`.
697 ///
698 /// Expected: unit(1) + fn(1) + if(1) = 3. No contribution from
699 /// `else`.
700 #[test]
701 fn python_if_else_does_not_overcount_229() {
702 check_metrics::<PythonParser>(
703 "def f(x):
704 if x > 0:
705 y = 1
706 else:
707 y = 2
708",
709 "foo.py",
710 |metric| {
711 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
712 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
713 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
714 insta::assert_json_snapshot!(
715 metric.cyclomatic,
716 @r#"
717 {
718 "sum": 3,
719 "value": 1,
720 "average": 3.0,
721 "min": 1,
722 "max": 2,
723 "modified": {
724 "sum": 3,
725 "value": 1,
726 "average": 3.0,
727 "min": 1,
728 "max": 2
729 }
730 }
731 "#
732 );
733 },
734 );
735 }
736
737 /// Companion to #229: a chained `if/elif/else` must count one
738 /// per `if` and per `elif`, never the bare `else`.
739 ///
740 /// Expected: unit(1) + fn(1) + if(1) + elif(1) + elif(1) = 5.
741 #[test]
742 fn python_if_elif_else_chain_229() {
743 check_metrics::<PythonParser>(
744 "def f(x):
745 if x == 1:
746 return 10
747 elif x == 2:
748 return 20
749 elif x == 3:
750 return 30
751 else:
752 return 0
753",
754 "foo.py",
755 |metric| {
756 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
757 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 5);
758 assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
759 },
760 );
761 }
762
763 /// The for/else feature must still count: the `else` body runs
764 /// only when the loop completes without `break`, which is a
765 /// distinct decision point.
766 ///
767 /// Expected: unit(1) + fn(1) + for(1) + else(1) = 4.
768 #[test]
769 fn python_for_else_still_counts_229() {
770 check_metrics::<PythonParser>(
771 "def f(xs):
772 for x in xs:
773 if x < 0:
774 break
775 else:
776 return True
777 return False
778",
779 "foo.py",
780 |metric| {
781 // fn body has: for(1) + if(1) + for/else(1) = 3 over base 1 -> max = 4
782 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
783 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 5);
784 assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
785 },
786 );
787 }
788
789 /// Symmetric to for/else: while/else also runs only on normal
790 /// completion of the loop.
791 ///
792 /// Expected: unit(1) + fn(1) + while(1) + else(1) = 4.
793 #[test]
794 fn python_while_else_still_counts_229() {
795 check_metrics::<PythonParser>(
796 "def f(n):
797 while n > 0:
798 n -= 1
799 else:
800 return True
801 return False
802",
803 "foo.py",
804 |metric| {
805 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
806 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
807 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
808 },
809 );
810 }
811
812 /// try/except/else: the `else` body runs only when no exception
813 /// was raised in `try`, mirroring loop-`else` semantics. Counts
814 /// alongside the `except` arm.
815 ///
816 /// Expected: unit(1) + fn(1) + except(1) + try/else(1) = 4.
817 #[test]
818 fn python_try_except_else_counts_229() {
819 check_metrics::<PythonParser>(
820 "def f():
821 try:
822 x = risky()
823 except ValueError:
824 x = -1
825 else:
826 x = x + 1
827 return x
828",
829 "foo.py",
830 |metric| {
831 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
832 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
833 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
834 },
835 );
836 }
837
838 /// `with` is unconditional resource management, not a branch, so it
839 /// must not add to cyclomatic complexity — matching the C-family
840 /// `using` sibling and textbook McCabe. Regression test for #418.
841 ///
842 /// Expected: unit(1) + fn(1) = 2; the `with` adds nothing.
843 #[test]
844 fn python_with_is_not_a_decision_point_418() {
845 check_metrics::<PythonParser>(
846 "def f():
847 with open('a') as fp:
848 return fp.read()
849",
850 "foo.py",
851 |metric| {
852 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 2);
853 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 2);
854 assert_eq!(metric.cyclomatic.cyclomatic_max(), 1);
855 },
856 );
857 }
858
859 /// A `with` managing multiple context managers (`with a, b:`) parses
860 /// as a single `with_statement` with one `with` keyword token, so it
861 /// stays uncounted just like the single-manager form. Companion to
862 /// #418.
863 ///
864 /// Expected: unit(1) + fn(1) = 2.
865 #[test]
866 fn python_with_multiple_managers_is_not_a_decision_point_418() {
867 check_metrics::<PythonParser>(
868 "def f(a, b):
869 with a, b:
870 return 1
871",
872 "foo.py",
873 |metric| {
874 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 2);
875 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 2);
876 },
877 );
878 }
879
880 /// `async with` reuses the same `with` keyword token as plain
881 /// `with`, so dropping `With` from the decision arm stops counting
882 /// it too. Companion to #418.
883 ///
884 /// Expected: unit(1) + fn(1) = 2; neither `async` nor `with` counts.
885 #[test]
886 fn python_async_with_is_not_a_decision_point_418() {
887 check_metrics::<PythonParser>(
888 "async def f():
889 async with open('a') as fp:
890 return fp.read()
891",
892 "foo.py",
893 |metric| {
894 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 2);
895 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 2);
896 },
897 );
898 }
899
900 /// Dropping `With` must not suppress real branches *inside* a `with`
901 /// body: an `if` in the body still counts. Guards against an
902 /// over-broad fix. Companion to #418.
903 ///
904 /// Expected: unit(1) + fn(1) + if(1) = 3; the `with` adds nothing.
905 #[test]
906 fn python_with_body_branch_still_counts_418() {
907 check_metrics::<PythonParser>(
908 "def f(x):
909 with open('a') as fp:
910 if x:
911 return fp.read()
912 return None
913",
914 "foo.py",
915 |metric| {
916 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
917 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
918 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
919 },
920 );
921 }
922
923 #[test]
924 fn python_simple_function() {
925 check_metrics::<PythonParser>(
926 "def f(a, b): # +2 (+1 unit space)
927 if a and b: # +2 (+1 and)
928 return 1
929 if c and d: # +2 (+1 and)
930 return 1",
931 "foo.py",
932 |metric| {
933 // nspace = 2 (func and unit)
934 insta::assert_json_snapshot!(
935 metric.cyclomatic,
936 @r#"
937 {
938 "sum": 6,
939 "value": 1,
940 "average": 6.0,
941 "min": 1,
942 "max": 5,
943 "modified": {
944 "sum": 6,
945 "value": 1,
946 "average": 6.0,
947 "min": 1,
948 "max": 5
949 }
950 }
951 "#
952 );
953 },
954 );
955 }
956
957 /// Python `match`/`case` (PEP 634, 3.10+): each non-bare-wildcard
958 /// arm contributes one standard decision; the containing
959 /// `match_statement` contributes one modified decision. A bare
960 /// `case _:` (no guard) is skipped, mirroring Rust's `MatchArm`
961 /// bare-wildcard filter. Regression test for #212.
962 #[test]
963 fn python_match_two_arm_wildcard() {
964 check_metrics::<PythonParser>(
965 "def f(x):
966 match x:
967 case 1:
968 return 'one'
969 case _:
970 return 'other'
971",
972 "foo.py",
973 |metric| {
974 // standard: 1 (unit) + 1 (fn) + 1 (case 1; case _ skipped) = 3
975 // modified: 1 (unit) + 1 (fn) + 1 (match_statement) = 3
976 // function space alone holds 1 decision -> max = 2
977 insta::assert_json_snapshot!(
978 metric.cyclomatic,
979 @r#"
980 {
981 "sum": 3,
982 "value": 1,
983 "average": 3.0,
984 "min": 1,
985 "max": 2,
986 "modified": {
987 "sum": 3,
988 "value": 1,
989 "average": 3.0,
990 "min": 1,
991 "max": 2
992 }
993 }
994 "#
995 );
996 },
997 );
998 }
999
1000 /// `case _ if guard:` still counts because the guard is an
1001 /// `if_clause` sibling on the `case_clause`, escaping the bare-
1002 /// wildcard filter. The guard's own `if` keyword token is also
1003 /// counted via the existing `If` arm (every `if` keyword in
1004 /// Python contributes a decision) — long-standing behaviour
1005 /// shared with regular `if` statements. Companion to the
1006 /// `python_match_case_guarded_wildcard_counts` test in `abc.rs`.
1007 #[test]
1008 fn python_match_guarded_wildcard_counts() {
1009 check_metrics::<PythonParser>(
1010 "def f(x):
1011 match x:
1012 case 1:
1013 return 'one'
1014 case _ if x > 0:
1015 return 'positive'
1016 case _:
1017 return 'other'
1018",
1019 "foo.py",
1020 |metric| {
1021 // standard: 1 (unit) + 1 (fn) + 1 (case 1)
1022 // + 1 (guarded `case _ if ...` — bare-_ filter
1023 // escaped by the guard)
1024 // + 1 (`if` keyword inside the guard)
1025 // = 5; bare `case _:` is filtered.
1026 // modified: 1 (unit) + 1 (fn) + 1 (match_statement)
1027 // + 1 (`if` keyword in the guard) = 4.
1028 insta::assert_json_snapshot!(
1029 metric.cyclomatic,
1030 @r#"
1031 {
1032 "sum": 5,
1033 "value": 1,
1034 "average": 5.0,
1035 "min": 1,
1036 "max": 4,
1037 "modified": {
1038 "sum": 4,
1039 "value": 1,
1040 "average": 4.0,
1041 "min": 1,
1042 "max": 3
1043 }
1044 }
1045 "#
1046 );
1047 },
1048 );
1049 }
1050
1051 #[test]
1052 fn python_1_level_nesting() {
1053 check_metrics::<PythonParser>(
1054 "def f(a, b): # +2 (+1 unit space)
1055 if a: # +1
1056 for i in range(b): # +1
1057 return 1",
1058 "foo.py",
1059 |metric| {
1060 // nspace = 2 (func and unit)
1061 insta::assert_json_snapshot!(
1062 metric.cyclomatic,
1063 @r#"
1064 {
1065 "sum": 4,
1066 "value": 1,
1067 "average": 4.0,
1068 "min": 1,
1069 "max": 3,
1070 "modified": {
1071 "sum": 4,
1072 "value": 1,
1073 "average": 4.0,
1074 "min": 1,
1075 "max": 3
1076 }
1077 }
1078 "#
1079 );
1080 },
1081 );
1082 }
1083
1084 #[test]
1085 fn rust_1_level_nesting() {
1086 check_metrics::<RustParser>(
1087 "fn f() { // +2 (+1 unit space)
1088 if true { // +1
1089 match true {
1090 true => println!(\"test\"), // +1
1091 false => println!(\"test\"), // +1
1092 }
1093 }
1094 }",
1095 "foo.rs",
1096 |metric| {
1097 // nspace = 2 (func and unit)
1098 insta::assert_json_snapshot!(
1099 metric.cyclomatic,
1100 @r#"
1101 {
1102 "sum": 5,
1103 "value": 1,
1104 "average": 5.0,
1105 "min": 1,
1106 "max": 4,
1107 "modified": {
1108 "sum": 4,
1109 "value": 1,
1110 "average": 4.0,
1111 "min": 1,
1112 "max": 3
1113 }
1114 }
1115 "#
1116 );
1117 },
1118 );
1119 }
1120
1121 /// Modified CCN: a match with N arms counts as 1 decision, not N.
1122 /// Bare `_ =>` wildcard arm does not count toward standard CCN (same
1123 /// as C-family `default:`).
1124 #[test]
1125 fn rust_match_modified() {
1126 check_metrics::<RustParser>(
1127 "fn f(x: u8) -> &'static str { // standard: +1 (unit) +1 (fn) +2 (arms 1,2) = 4; modified: +1 (unit) +1 (fn) +1 (MatchExpr) = 3
1128 match x {
1129 1 => \"one\",
1130 2 => \"two\",
1131 _ => \"other\",
1132 }
1133 }",
1134 "foo.rs",
1135 |metric| {
1136 insta::assert_json_snapshot!(
1137 metric.cyclomatic,
1138 @r#"
1139 {
1140 "sum": 4,
1141 "value": 1,
1142 "average": 4.0,
1143 "min": 1,
1144 "max": 3,
1145 "modified": {
1146 "sum": 3,
1147 "value": 1,
1148 "average": 3.0,
1149 "min": 1,
1150 "max": 2
1151 }
1152 }
1153 "#
1154 );
1155 },
1156 );
1157 }
1158
1159 // The `?` operator (TryExpression) is the configurable arm (#409).
1160 // Fixture has exactly N=3 `?` operators in a single function. With
1161 // counting on (the default) each adds +1 to both standard and
1162 // modified; with counting off they add nothing. The two runs must
1163 // therefore differ by exactly N on both sub-metrics.
1164 const RUST_TRY_FIXTURE: &str = "fn f(s: &str) -> Result<i64, std::num::ParseIntError> {
1165 let a: i64 = s.parse()?;
1166 let b: i64 = s.parse()?;
1167 let c: i64 = s.parse()?;
1168 Ok(a + b + c)
1169 }";
1170 const RUST_TRY_COUNT: u64 = 3;
1171
1172 fn rust_cyclomatic_with_try(count_try: bool) -> super::Stats {
1173 let func_space = crate::analyze(
1174 crate::Source::new(crate::LANG::Rust, RUST_TRY_FIXTURE.as_bytes())
1175 .with_name(Some("try.rs".to_owned())),
1176 crate::MetricsOptions::default().with_count_cyclomatic_try(count_try),
1177 )
1178 .expect("analyze must succeed on a well-formed Rust fixture");
1179 func_space.metrics.cyclomatic
1180 }
1181
1182 #[test]
1183 fn rust_try_toggle_differs_by_exactly_n() {
1184 let with = rust_cyclomatic_with_try(true);
1185 let without = rust_cyclomatic_with_try(false);
1186
1187 // Headline acceptance (#409): the toggle's whole effect is the N
1188 // `?` operators, on both standard and modified cyclomatic.
1189 assert_eq!(
1190 with.cyclomatic_sum() - without.cyclomatic_sum(),
1191 RUST_TRY_COUNT,
1192 "standard cyclomatic must drop by exactly N when `?` is not counted"
1193 );
1194 assert_eq!(
1195 with.cyclomatic_modified_sum() - without.cyclomatic_modified_sum(),
1196 RUST_TRY_COUNT,
1197 "modified cyclomatic must drop by exactly N when `?` is not counted"
1198 );
1199 // Guard against a no-op toggle: the two runs must actually differ.
1200 assert_ne!(with.cyclomatic_sum(), without.cyclomatic_sum());
1201 }
1202
1203 #[test]
1204 fn rust_try_default_counts() {
1205 // The default (no options) must keep counting `?`, preserving
1206 // every published metric value (#409). Equivalent to the
1207 // `count_try == true` run above.
1208 let default_path = {
1209 let func_space = crate::analyze(
1210 crate::Source::new(crate::LANG::Rust, RUST_TRY_FIXTURE.as_bytes())
1211 .with_name(Some("try.rs".to_owned())),
1212 crate::MetricsOptions::default(),
1213 )
1214 .expect("analyze must succeed on a well-formed Rust fixture");
1215 func_space.metrics.cyclomatic
1216 };
1217 let explicit_on = rust_cyclomatic_with_try(true);
1218 assert_eq!(default_path.cyclomatic_sum(), explicit_on.cyclomatic_sum());
1219 assert_eq!(
1220 default_path.cyclomatic_modified_sum(),
1221 explicit_on.cyclomatic_modified_sum()
1222 );
1223 // unit(1) + fn(entry 1 + 3*`?` = 4) = 5 standard; modified same
1224 // shape (no match container here): 1 + 4 = 5.
1225 assert_eq!(default_path.cyclomatic_sum(), 5);
1226 assert_eq!(default_path.cyclomatic_modified_sum(), 5);
1227 }
1228
1229 #[test]
1230 fn c_switch() {
1231 check_metrics::<CParser>(
1232 "void f() { // +2 (+1 unit space)
1233 switch (1) {
1234 case 1: // +1
1235 printf(\"one\");
1236 break;
1237 case 2: // +1
1238 printf(\"two\");
1239 break;
1240 case 3: // +1
1241 printf(\"three\");
1242 break;
1243 default:
1244 printf(\"all\");
1245 break;
1246 }
1247 }",
1248 "foo.c",
1249 |metric| {
1250 // nspace = 2 (func and unit)
1251 insta::assert_json_snapshot!(
1252 metric.cyclomatic,
1253 @r#"
1254 {
1255 "sum": 5,
1256 "value": 1,
1257 "average": 5.0,
1258 "min": 1,
1259 "max": 4,
1260 "modified": {
1261 "sum": 3,
1262 "value": 1,
1263 "average": 3.0,
1264 "min": 1,
1265 "max": 2
1266 }
1267 }
1268 "#
1269 );
1270 },
1271 );
1272 }
1273
1274 /// Modified CCN: 3 case arms in one switch collapse to 1 decision.
1275 #[test]
1276 fn c_switch_modified() {
1277 check_metrics::<CParser>(
1278 "void f() {
1279 switch (x) {
1280 case 1: break;
1281 case 2: break;
1282 case 3: break;
1283 default: break;
1284 }
1285 }",
1286 "foo.c",
1287 |metric| {
1288 // standard: unit(1) + fn(1) + 3 cases = 5
1289 // modified: unit(1) + fn(1) + switch(1) = 3
1290 insta::assert_json_snapshot!(
1291 metric.cyclomatic,
1292 @r#"
1293 {
1294 "sum": 5,
1295 "value": 1,
1296 "average": 5.0,
1297 "min": 1,
1298 "max": 4,
1299 "modified": {
1300 "sum": 3,
1301 "value": 1,
1302 "average": 3.0,
1303 "min": 1,
1304 "max": 2
1305 }
1306 }
1307 "#
1308 );
1309 },
1310 );
1311 }
1312
1313 #[test]
1314 fn c_real_function() {
1315 check_metrics::<CParser>(
1316 "int sumOfPrimes(int max) { // +2 (+1 unit space)
1317 int total = 0;
1318 OUT: for (int i = 1; i <= max; ++i) { // +1
1319 for (int j = 2; j < i; ++j) { // +1
1320 if (i % j == 0) { // +1
1321 continue OUT;
1322 }
1323 }
1324 total += i;
1325 }
1326 return total;
1327 }",
1328 "foo.c",
1329 |metric| {
1330 // nspace = 2 (func and unit)
1331 insta::assert_json_snapshot!(
1332 metric.cyclomatic,
1333 @r#"
1334 {
1335 "sum": 5,
1336 "value": 1,
1337 "average": 5.0,
1338 "min": 1,
1339 "max": 4,
1340 "modified": {
1341 "sum": 5,
1342 "value": 1,
1343 "average": 5.0,
1344 "min": 1,
1345 "max": 4
1346 }
1347 }
1348 "#
1349 );
1350 },
1351 );
1352 }
1353
1354 #[test]
1355 fn c_unit_before() {
1356 check_metrics::<CParser>(
1357 "
1358 int a=42;
1359 if(a==42) //+2(+1 unit space)
1360 {
1361
1362 }
1363 if(a==34) //+1
1364 {
1365
1366 }
1367 int sumOfPrimes(int max) { // +1
1368 int total = 0;
1369 OUT: for (int i = 1; i <= max; ++i) { // +1
1370 for (int j = 2; j < i; ++j) { // +1
1371 if (i % j == 0) { // +1
1372 continue OUT;
1373 }
1374 }
1375 total += i;
1376 }
1377 return total;
1378 }",
1379 "foo.c",
1380 |metric| {
1381 // nspace = 2 (func and unit)
1382 insta::assert_json_snapshot!(
1383 metric.cyclomatic,
1384 @r#"
1385 {
1386 "sum": 7,
1387 "value": 3,
1388 "average": 7.0,
1389 "min": 3,
1390 "max": 4,
1391 "modified": {
1392 "sum": 7,
1393 "value": 3,
1394 "average": 7.0,
1395 "min": 3,
1396 "max": 4
1397 }
1398 }
1399 "#
1400 );
1401 },
1402 );
1403 }
1404
1405 /// Test to handle the case of min and max when merge happen before the final value of one module are set.
1406 /// In this case the min value should be 3 because the unit space has 2 branches and a complexity of 3
1407 /// while the function sumOfPrimes has a complexity of 4.
1408 #[test]
1409 fn c_unit_after() {
1410 check_metrics::<CParser>(
1411 "
1412 int sumOfPrimes(int max) { // +1
1413 int total = 0;
1414 OUT: for (int i = 1; i <= max; ++i) { // +1
1415 for (int j = 2; j < i; ++j) { // +1
1416 if (i % j == 0) { // +1
1417 continue OUT;
1418 }
1419 }
1420 total += i;
1421 }
1422 return total;
1423 }
1424
1425 int a=42;
1426 if(a==42) //+2(+1 unit space)
1427 {
1428
1429 }
1430 if(a==34) //+1
1431 {
1432
1433 }",
1434 "foo.c",
1435 |metric| {
1436 // nspace = 2 (func and unit)
1437 insta::assert_json_snapshot!(
1438 metric.cyclomatic,
1439 @r#"
1440 {
1441 "sum": 7,
1442 "value": 3,
1443 "average": 7.0,
1444 "min": 3,
1445 "max": 4,
1446 "modified": {
1447 "sum": 7,
1448 "value": 3,
1449 "average": 7.0,
1450 "min": 3,
1451 "max": 4
1452 }
1453 }
1454 "#
1455 );
1456 },
1457 );
1458 }
1459
1460 #[test]
1461 fn java_simple_class() {
1462 check_metrics::<JavaParser>(
1463 "
1464 public class Example { // +2 (+1 unit space)
1465 int a = 10;
1466 boolean b = (a > 5) ? true : false; // +1
1467 boolean c = b && true; // +1
1468
1469 public void m1() { // +1
1470 if (a % 2 == 0) { // +1
1471 b = b || c; // +1
1472 }
1473 }
1474 public void m2() { // +1
1475 while (a > 3) { // +1
1476 m1();
1477 a--;
1478 }
1479 }
1480 }",
1481 "foo.java",
1482 |metric| {
1483 // nspace = 4 (unit, class and 2 methods)
1484 insta::assert_json_snapshot!(
1485 metric.cyclomatic,
1486 @r#"
1487 {
1488 "sum": 9,
1489 "value": 1,
1490 "average": 4.5,
1491 "min": 1,
1492 "max": 3,
1493 "modified": {
1494 "sum": 9,
1495 "value": 1,
1496 "average": 4.5,
1497 "min": 1,
1498 "max": 3
1499 }
1500 }
1501 "#
1502 );
1503 },
1504 );
1505 }
1506
1507 #[test]
1508 fn java_real_class() {
1509 check_metrics::<JavaParser>(
1510 "
1511 public class Matrix { // +2 (+1 unit space)
1512 private int[][] m = new int[5][5];
1513
1514 public void init() { // +1
1515 for (int i = 0; i < m.length; i++) { // +1
1516 for (int j = 0; j < m[i].length; j++) { // +1
1517 m[i][j] = i * j;
1518 }
1519 }
1520 }
1521 public int compute(int i, int j) { // +1
1522 try {
1523 return m[i][j] / m[j][i];
1524 } catch (ArithmeticException e) { // +1
1525 return -1;
1526 } catch (ArrayIndexOutOfBoundsException e) { // +1
1527 return -2;
1528 }
1529 }
1530 public void print(int result) { // +1
1531 switch (result) {
1532 case -1: // +1
1533 System.out.println(\"Division by zero\");
1534 break;
1535 case -2: // +1
1536 System.out.println(\"Wrong index number\");
1537 break;
1538 default:
1539 System.out.println(\"The result is \" + result);
1540 }
1541 }
1542 }",
1543 "foo.java",
1544 |metric| {
1545 // nspace = 5 (unit, class and 3 methods)
1546 insta::assert_json_snapshot!(
1547 metric.cyclomatic,
1548 @r#"
1549 {
1550 "sum": 11,
1551 "value": 1,
1552 "average": 3.6666666666666665,
1553 "min": 1,
1554 "max": 3,
1555 "modified": {
1556 "sum": 10,
1557 "value": 1,
1558 "average": 3.3333333333333335,
1559 "min": 1,
1560 "max": 3
1561 }
1562 }
1563 "#
1564 );
1565 },
1566 );
1567 }
1568
1569 /// Modified CCN: Java switch with 2 cases counts as 1 (not 2).
1570 #[test]
1571 fn java_switch_modified() {
1572 check_metrics::<JavaParser>(
1573 "public class A {
1574 public void print(int result) {
1575 switch (result) {
1576 case -1:
1577 System.out.println(\"minus one\");
1578 break;
1579 case -2:
1580 System.out.println(\"minus two\");
1581 break;
1582 default:
1583 System.out.println(\"other\");
1584 }
1585 }
1586 }",
1587 "foo.java",
1588 |metric| {
1589 // standard: unit(1) + class(1) + fn(1) + 2 cases = 5
1590 // modified: unit(1) + class(1) + fn(1) + switch(1) = 4
1591 insta::assert_json_snapshot!(
1592 metric.cyclomatic,
1593 @r#"
1594 {
1595 "sum": 5,
1596 "value": 1,
1597 "average": 5.0,
1598 "min": 1,
1599 "max": 3,
1600 "modified": {
1601 "sum": 4,
1602 "value": 1,
1603 "average": 4.0,
1604 "min": 1,
1605 "max": 2
1606 }
1607 }
1608 "#
1609 );
1610 },
1611 );
1612 }
1613
1614 #[test]
1615 fn csharp_simple_class() {
1616 check_metrics::<CsharpParser>(
1617 "public class Example {
1618 int a = 10;
1619 bool b = (a > 5) ? true : false;
1620 bool c = b && true;
1621
1622 public void M1() {
1623 if (a % 2 == 0) {
1624 b = b || c;
1625 }
1626 }
1627 public void M2() {
1628 while (a > 3) {
1629 M1();
1630 a--;
1631 }
1632 }
1633 }",
1634 "foo.cs",
1635 |metric| {
1636 insta::assert_json_snapshot!(
1637 metric.cyclomatic,
1638 @r#"
1639 {
1640 "sum": 9,
1641 "value": 1,
1642 "average": 4.5,
1643 "min": 1,
1644 "max": 3,
1645 "modified": {
1646 "sum": 9,
1647 "value": 1,
1648 "average": 4.5,
1649 "min": 1,
1650 "max": 3
1651 }
1652 }
1653 "#
1654 );
1655 },
1656 );
1657 }
1658
1659 #[test]
1660 fn csharp_real_class() {
1661 check_metrics::<CsharpParser>(
1662 "public class Matrix {
1663 private int[,] m = new int[5, 5];
1664
1665 public void Init() {
1666 for (int i = 0; i < 5; i++) {
1667 for (int j = 0; j < 5; j++) {
1668 m[i, j] = i * j;
1669 }
1670 }
1671 }
1672 public int Compute(int i, int j) {
1673 try {
1674 return m[i, j] / m[j, i];
1675 } catch (System.DivideByZeroException) {
1676 return -1;
1677 } catch (System.IndexOutOfRangeException) {
1678 return -2;
1679 }
1680 }
1681 public void Print(int result) {
1682 switch (result) {
1683 case -1:
1684 System.Console.WriteLine(\"Division by zero\");
1685 break;
1686 case -2:
1687 System.Console.WriteLine(\"Wrong index number\");
1688 break;
1689 default:
1690 System.Console.WriteLine(\"The result is \" + result);
1691 break;
1692 }
1693 }
1694 }",
1695 "foo.cs",
1696 |metric| {
1697 insta::assert_json_snapshot!(
1698 metric.cyclomatic,
1699 @r#"
1700 {
1701 "sum": 11,
1702 "value": 1,
1703 "average": 3.6666666666666665,
1704 "min": 1,
1705 "max": 3,
1706 "modified": {
1707 "sum": 10,
1708 "value": 1,
1709 "average": 3.3333333333333335,
1710 "min": 1,
1711 "max": 3
1712 }
1713 }
1714 "#
1715 );
1716 },
1717 );
1718 }
1719
1720 #[test]
1721 fn csharp_anonymous_method() {
1722 check_metrics::<CsharpParser>(
1723 "public class A {
1724 public void M() {
1725 System.Action f = delegate(int x) {
1726 if (x > 0) {
1727 System.Console.WriteLine(x);
1728 }
1729 };
1730 }
1731 }",
1732 "foo.cs",
1733 |metric| {
1734 insta::assert_json_snapshot!(
1735 metric.cyclomatic,
1736 @r#"
1737 {
1738 "sum": 5,
1739 "value": 1,
1740 "average": 2.5,
1741 "min": 1,
1742 "max": 2,
1743 "modified": {
1744 "sum": 5,
1745 "value": 1,
1746 "average": 2.5,
1747 "min": 1,
1748 "max": 2
1749 }
1750 }
1751 "#
1752 );
1753 },
1754 );
1755 }
1756
1757 #[test]
1758 fn csharp_switch_expression_arms() {
1759 // Each non-default arm of a switch_expression contributes +1.
1760 // The discard arm `_ =>` is excluded (issue #282), mirroring
1761 // Rust's `_ =>` and Java/C#'s `default:` treatment.
1762 check_metrics::<CsharpParser>(
1763 "public class A {
1764 public string Name(int n) =>
1765 n switch {
1766 1 => \"one\",
1767 2 => \"two\",
1768 3 => \"three\",
1769 _ => \"other\"
1770 };
1771 }",
1772 "foo.cs",
1773 |metric| {
1774 // expected: unit(1) + class(1) + fn(base 1 + 3 explicit arms;
1775 // `_ =>` skipped) = sum 6, max 4. modified =
1776 // unit(1) + class(1) + fn(base 1 + switch expr 1) = 4.
1777 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 6);
1778 assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
1779 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
1780 insta::assert_json_snapshot!(
1781 metric.cyclomatic,
1782 @r#"
1783 {
1784 "sum": 6,
1785 "value": 1,
1786 "average": 6.0,
1787 "min": 1,
1788 "max": 4,
1789 "modified": {
1790 "sum": 4,
1791 "value": 1,
1792 "average": 4.0,
1793 "min": 1,
1794 "max": 2
1795 }
1796 }
1797 "#
1798 );
1799 },
1800 );
1801 }
1802
1803 /// Regression #282: the bare discard arm `_ =>` in a C# switch
1804 /// expression must NOT contribute to standard CCN, mirroring the
1805 /// C-family `default:` rule.
1806 #[test]
1807 fn csharp_switch_expression_discard_arm_not_counted() {
1808 check_metrics::<CsharpParser>(
1809 "public class A {
1810 public string Name(int n) =>
1811 n switch {
1812 1 => \"one\",
1813 _ => \"other\"
1814 };
1815 }",
1816 "foo.cs",
1817 |metric| {
1818 // expected: unit(1) + class(1) + fn(base 1 + 1 explicit;
1819 // `_ =>` skipped) = 4, max 2.
1820 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
1821 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
1822 },
1823 );
1824 }
1825
1826 /// Regression #282: `var _` is also a discard pattern and must be
1827 /// excluded from standard CCN.
1828 #[test]
1829 fn csharp_switch_expression_var_underscore_not_counted() {
1830 check_metrics::<CsharpParser>(
1831 "public class A {
1832 public string Name(int n) =>
1833 n switch {
1834 1 => \"one\",
1835 var _ => \"other\"
1836 };
1837 }",
1838 "foo.cs",
1839 |metric| {
1840 // expected: unit(1) + class(1) + fn(base 1 + 1 explicit;
1841 // `var _ =>` skipped) = 4, max 2.
1842 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
1843 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
1844 },
1845 );
1846 }
1847
1848 /// Regression #282: a guarded discard arm `_ when g => …` is NOT a
1849 /// bare wildcard — the `when` guard adds a non-trivial decision —
1850 /// so the arm still contributes one standard decision, mirroring
1851 /// Rust's `_ if g` rule.
1852 #[test]
1853 fn csharp_switch_expression_guarded_discard_still_counts() {
1854 check_metrics::<CsharpParser>(
1855 "public class A {
1856 public string Name(int n) =>
1857 n switch {
1858 1 => \"one\",
1859 _ when n > 10 => \"big\",
1860 _ => \"other\"
1861 };
1862 }",
1863 "foo.cs",
1864 |metric| {
1865 // expected: unit(1) + class(1) + fn(base 1 + 1 explicit +
1866 // 1 guarded discard; bare `_ =>` skipped) = 5,
1867 // max 3.
1868 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
1869 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
1870 },
1871 );
1872 }
1873
1874 /// Regression #303 / #282: a typed-discard arm `int _ =>` is NOT
1875 /// a bare discard — the type test (`predefined_type`) is a
1876 /// non-trivial decision — so the arm still contributes one
1877 /// standard decision. Locks in the
1878 /// `DeclarationPattern → _ => return NotDiscard` catch-all in
1879 /// `csharp_switch_expression_arm_is_bare_discard`.
1880 #[test]
1881 fn csharp_switch_expression_typed_discard_still_counts() {
1882 check_metrics::<CsharpParser>(
1883 "public class A {
1884 public string Name(object n) =>
1885 n switch {
1886 1 => \"one\",
1887 int _ => \"int\",
1888 _ => \"other\"
1889 };
1890 }",
1891 "foo.cs",
1892 |metric| {
1893 // expected: unit(1) + class(1) + fn(base 1 + 1 explicit `1` +
1894 // 1 typed-discard `int _`; bare `_ =>` skipped) = 5,
1895 // max 3.
1896 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
1897 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
1898 },
1899 );
1900 }
1901
1902 /// Regression #303 / #282: a guarded `var _ when g =>` is NOT a
1903 /// bare discard — the `when` guard adds a non-trivial decision —
1904 /// so the arm still contributes one standard decision. Exercises
1905 /// the `DeclarationPattern` arm of `classify_pattern` combined
1906 /// with the post-pattern `WhenClause` sweep.
1907 #[test]
1908 fn csharp_switch_expression_guarded_var_underscore_still_counts() {
1909 check_metrics::<CsharpParser>(
1910 "public class A {
1911 public string Name(int n) =>
1912 n switch {
1913 1 => \"one\",
1914 var _ when n > 10 => \"big\",
1915 _ => \"other\"
1916 };
1917 }",
1918 "foo.cs",
1919 |metric| {
1920 // expected: unit(1) + class(1) + fn(base 1 + 1 explicit `1` +
1921 // 1 guarded `var _`; bare `_ =>` skipped) = 5,
1922 // max 3.
1923 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
1924 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
1925 },
1926 );
1927 }
1928
1929 /// Modified CCN: C# switch statement with 2 cases counts as 1.
1930 #[test]
1931 fn csharp_switch_modified() {
1932 check_metrics::<CsharpParser>(
1933 "public class A {
1934 public string Describe(int n) {
1935 switch (n) {
1936 case 1:
1937 return \"one\";
1938 case 2:
1939 return \"two\";
1940 default:
1941 return \"other\";
1942 }
1943 }
1944 }",
1945 "foo.cs",
1946 |metric| {
1947 // standard: unit(1) + class(1) + fn(1) + 2 cases = 5
1948 // modified: unit(1) + class(1) + fn(1) + switch(1) = 4
1949 insta::assert_json_snapshot!(
1950 metric.cyclomatic,
1951 @r#"
1952 {
1953 "sum": 5,
1954 "value": 1,
1955 "average": 5.0,
1956 "min": 1,
1957 "max": 3,
1958 "modified": {
1959 "sum": 4,
1960 "value": 1,
1961 "average": 4.0,
1962 "min": 1,
1963 "max": 2
1964 }
1965 }
1966 "#
1967 );
1968 },
1969 );
1970 }
1971
1972 #[test]
1973 fn csharp_null_coalescing_and_conditional_access() {
1974 // Each `??` and `?.` is +1 cyclomatic.
1975 check_metrics::<CsharpParser>(
1976 "public class A {
1977 public int? Get(string s, A b) {
1978 return s?.Length ?? b?.Get(null, null) ?? 0;
1979 }
1980 }",
1981 "foo.cs",
1982 |metric| {
1983 insta::assert_json_snapshot!(
1984 metric.cyclomatic,
1985 @r#"
1986 {
1987 "sum": 7,
1988 "value": 1,
1989 "average": 7.0,
1990 "min": 1,
1991 "max": 5,
1992 "modified": {
1993 "sum": 7,
1994 "value": 1,
1995 "average": 7.0,
1996 "min": 1,
1997 "max": 5
1998 }
1999 }
2000 "#
2001 );
2002 },
2003 );
2004 }
2005
2006 #[test]
2007 fn javascript_simple_function() {
2008 check_metrics::<JavascriptParser>(
2009 "function f(a, b) { // +2 (+1 unit space)
2010 if (a) { // +1
2011 return a;
2012 } else if (b) { // +1
2013 return b;
2014 }
2015 return 0;
2016 }",
2017 "foo.js",
2018 |metric| {
2019 insta::assert_json_snapshot!(
2020 metric.cyclomatic,
2021 @r#"
2022 {
2023 "sum": 4,
2024 "value": 1,
2025 "average": 4.0,
2026 "min": 1,
2027 "max": 3,
2028 "modified": {
2029 "sum": 4,
2030 "value": 1,
2031 "average": 4.0,
2032 "min": 1,
2033 "max": 3
2034 }
2035 }
2036 "#
2037 );
2038 },
2039 );
2040 }
2041
2042 #[test]
2043 fn javascript_switch() {
2044 check_metrics::<JavascriptParser>(
2045 "function f() { // +2 (+1 unit space)
2046 switch (x) {
2047 case 1: // +1
2048 console.log(\"one\");
2049 break;
2050 case 2: // +1
2051 console.log(\"two\");
2052 break;
2053 case 3: // +1
2054 console.log(\"three\");
2055 break;
2056 default:
2057 console.log(\"other\");
2058 break;
2059 }
2060 }",
2061 "foo.js",
2062 |metric| {
2063 insta::assert_json_snapshot!(
2064 metric.cyclomatic,
2065 @r#"
2066 {
2067 "sum": 5,
2068 "value": 1,
2069 "average": 5.0,
2070 "min": 1,
2071 "max": 4,
2072 "modified": {
2073 "sum": 3,
2074 "value": 1,
2075 "average": 3.0,
2076 "min": 1,
2077 "max": 2
2078 }
2079 }
2080 "#
2081 );
2082 },
2083 );
2084 }
2085
2086 /// Modified CCN: JS switch with 3 cases collapses to 1.
2087 #[test]
2088 fn javascript_switch_modified() {
2089 check_metrics::<JavascriptParser>(
2090 "function f(x) {
2091 switch (x) {
2092 case 1: return 'one';
2093 case 2: return 'two';
2094 case 3: return 'three';
2095 }
2096 }",
2097 "foo.js",
2098 |metric| {
2099 // standard: unit(1) + fn(1) + 3 cases = 5
2100 // modified: unit(1) + fn(1) + switch(1) = 3
2101 insta::assert_json_snapshot!(
2102 metric.cyclomatic,
2103 @r#"
2104 {
2105 "sum": 5,
2106 "value": 1,
2107 "average": 5.0,
2108 "min": 1,
2109 "max": 4,
2110 "modified": {
2111 "sum": 3,
2112 "value": 1,
2113 "average": 3.0,
2114 "min": 1,
2115 "max": 2
2116 }
2117 }
2118 "#
2119 );
2120 },
2121 );
2122 }
2123
2124 #[test]
2125 fn go_simple_function() {
2126 check_metrics::<GoParser>(
2127 "package main
2128 func f() {}",
2129 "foo.go",
2130 |metric| {
2131 // nspace = 2 (file unit + func), each base 1.
2132 insta::assert_json_snapshot!(
2133 metric.cyclomatic,
2134 @r#"
2135 {
2136 "sum": 2,
2137 "value": 1,
2138 "average": 2.0,
2139 "min": 1,
2140 "max": 1,
2141 "modified": {
2142 "sum": 2,
2143 "value": 1,
2144 "average": 2.0,
2145 "min": 1,
2146 "max": 1
2147 }
2148 }
2149 "#
2150 );
2151 },
2152 );
2153 }
2154
2155 #[test]
2156 fn go_if_else() {
2157 check_metrics::<GoParser>(
2158 "package main
2159 func f(x bool) { // +2 (+1 unit)
2160 if x { // +1
2161 } else {
2162 }
2163 }",
2164 "foo.go",
2165 |metric| {
2166 // `else` clause attaches to the same if_statement node and is
2167 // not counted again.
2168 insta::assert_json_snapshot!(
2169 metric.cyclomatic,
2170 @r#"
2171 {
2172 "sum": 3,
2173 "value": 1,
2174 "average": 3.0,
2175 "min": 1,
2176 "max": 2,
2177 "modified": {
2178 "sum": 3,
2179 "value": 1,
2180 "average": 3.0,
2181 "min": 1,
2182 "max": 2
2183 }
2184 }
2185 "#
2186 );
2187 },
2188 );
2189 }
2190
2191 #[test]
2192 fn go_else_if_chain() {
2193 check_metrics::<GoParser>(
2194 "package main
2195 func f(x int) { // +2 (+1 unit)
2196 if x > 0 { // +1
2197 } else if x < 0 { // +1 (nested if_statement)
2198 } else if x == 0 { // +1 (nested if_statement)
2199 } else {
2200 }
2201 }",
2202 "foo.go",
2203 |metric| {
2204 // tree-sitter-go represents `else if` as a nested
2205 // if_statement under the parent's `else` clause; each nested
2206 // if contributes +1.
2207 insta::assert_json_snapshot!(
2208 metric.cyclomatic,
2209 @r#"
2210 {
2211 "sum": 5,
2212 "value": 1,
2213 "average": 5.0,
2214 "min": 1,
2215 "max": 4,
2216 "modified": {
2217 "sum": 5,
2218 "value": 1,
2219 "average": 5.0,
2220 "min": 1,
2221 "max": 4
2222 }
2223 }
2224 "#
2225 );
2226 },
2227 );
2228 }
2229
2230 #[test]
2231 fn go_for_loop() {
2232 check_metrics::<GoParser>(
2233 "package main
2234 func f() { // +2 (+1 unit)
2235 for i := 0; i < 10; i++ { // +1
2236 }
2237 }",
2238 "foo.go",
2239 |metric| {
2240 insta::assert_json_snapshot!(
2241 metric.cyclomatic,
2242 @r#"
2243 {
2244 "sum": 3,
2245 "value": 1,
2246 "average": 3.0,
2247 "min": 1,
2248 "max": 2,
2249 "modified": {
2250 "sum": 3,
2251 "value": 1,
2252 "average": 3.0,
2253 "min": 1,
2254 "max": 2
2255 }
2256 }
2257 "#
2258 );
2259 },
2260 );
2261 }
2262
2263 #[test]
2264 fn go_for_range() {
2265 check_metrics::<GoParser>(
2266 "package main
2267 func f(xs []int) { // +2 (+1 unit)
2268 for _, v := range xs { // +1
2269 _ = v
2270 }
2271 }",
2272 "foo.go",
2273 |metric| {
2274 // range_clause is a child of for_statement; only the
2275 // for_statement contributes.
2276 insta::assert_json_snapshot!(
2277 metric.cyclomatic,
2278 @r#"
2279 {
2280 "sum": 3,
2281 "value": 1,
2282 "average": 3.0,
2283 "min": 1,
2284 "max": 2,
2285 "modified": {
2286 "sum": 3,
2287 "value": 1,
2288 "average": 3.0,
2289 "min": 1,
2290 "max": 2
2291 }
2292 }
2293 "#
2294 );
2295 },
2296 );
2297 }
2298
2299 #[test]
2300 fn go_switch() {
2301 check_metrics::<GoParser>(
2302 "package main
2303 func f(x int) { // +2 (+1 unit)
2304 switch x {
2305 case 1: // +1
2306 case 2: // +1
2307 default: // not counted
2308 }
2309 }",
2310 "foo.go",
2311 |metric| {
2312 insta::assert_json_snapshot!(
2313 metric.cyclomatic,
2314 @r#"
2315 {
2316 "sum": 4,
2317 "value": 1,
2318 "average": 4.0,
2319 "min": 1,
2320 "max": 3,
2321 "modified": {
2322 "sum": 3,
2323 "value": 1,
2324 "average": 3.0,
2325 "min": 1,
2326 "max": 2
2327 }
2328 }
2329 "#
2330 );
2331 },
2332 );
2333 }
2334
2335 /// Modified CCN: Go switch with 3 cases collapses to 1.
2336 #[test]
2337 fn go_switch_modified() {
2338 check_metrics::<GoParser>(
2339 "package main
2340 func f(x int) {
2341 switch x {
2342 case 1:
2343 println(\"one\")
2344 case 2:
2345 println(\"two\")
2346 case 3:
2347 println(\"three\")
2348 }
2349 }",
2350 "foo.go",
2351 |metric| {
2352 // standard: unit(1) + fn(1) + 3 cases = 5
2353 // modified: unit(1) + fn(1) + switch(1) = 3
2354 insta::assert_json_snapshot!(
2355 metric.cyclomatic,
2356 @r#"
2357 {
2358 "sum": 5,
2359 "value": 1,
2360 "average": 5.0,
2361 "min": 1,
2362 "max": 4,
2363 "modified": {
2364 "sum": 3,
2365 "value": 1,
2366 "average": 3.0,
2367 "min": 1,
2368 "max": 2
2369 }
2370 }
2371 "#
2372 );
2373 },
2374 );
2375 }
2376
2377 #[test]
2378 fn go_type_switch() {
2379 check_metrics::<GoParser>(
2380 "package main
2381 func f(x interface{}) { // +2 (+1 unit)
2382 switch x.(type) {
2383 case int: // +1
2384 case string: // +1
2385 }
2386 }",
2387 "foo.go",
2388 |metric| {
2389 insta::assert_json_snapshot!(
2390 metric.cyclomatic,
2391 @r#"
2392 {
2393 "sum": 4,
2394 "value": 1,
2395 "average": 4.0,
2396 "min": 1,
2397 "max": 3,
2398 "modified": {
2399 "sum": 3,
2400 "value": 1,
2401 "average": 3.0,
2402 "min": 1,
2403 "max": 2
2404 }
2405 }
2406 "#
2407 );
2408 },
2409 );
2410 }
2411
2412 #[test]
2413 fn go_select() {
2414 check_metrics::<GoParser>(
2415 "package main
2416 func f(c1, c2 chan int) { // +2 (+1 unit)
2417 select {
2418 case <-c1: // +1
2419 case <-c2: // +1
2420 default: // not counted
2421 }
2422 }",
2423 "foo.go",
2424 |metric| {
2425 insta::assert_json_snapshot!(
2426 metric.cyclomatic,
2427 @r#"
2428 {
2429 "sum": 4,
2430 "value": 1,
2431 "average": 4.0,
2432 "min": 1,
2433 "max": 3,
2434 "modified": {
2435 "sum": 3,
2436 "value": 1,
2437 "average": 3.0,
2438 "min": 1,
2439 "max": 2
2440 }
2441 }
2442 "#
2443 );
2444 },
2445 );
2446 }
2447
2448 #[test]
2449 fn go_logical_operators() {
2450 check_metrics::<GoParser>(
2451 "package main
2452 func f(a, b, c bool) { // +2 (+1 unit)
2453 if a && b || c { // +1 if, +1 &&, +1 ||
2454 }
2455 }",
2456 "foo.go",
2457 |metric| {
2458 insta::assert_json_snapshot!(
2459 metric.cyclomatic,
2460 @r#"
2461 {
2462 "sum": 5,
2463 "value": 1,
2464 "average": 5.0,
2465 "min": 1,
2466 "max": 4,
2467 "modified": {
2468 "sum": 5,
2469 "value": 1,
2470 "average": 5.0,
2471 "min": 1,
2472 "max": 4
2473 }
2474 }
2475 "#
2476 );
2477 },
2478 );
2479 }
2480
2481 #[test]
2482 fn go_defer_and_go_do_not_count() {
2483 check_metrics::<GoParser>(
2484 "package main
2485 func f() { // +2 (+1 unit)
2486 defer cleanup()
2487 go work()
2488 }",
2489 "foo.go",
2490 |metric| {
2491 // defer_statement and go_statement are not branches.
2492 insta::assert_json_snapshot!(
2493 metric.cyclomatic,
2494 @r#"
2495 {
2496 "sum": 2,
2497 "value": 1,
2498 "average": 2.0,
2499 "min": 1,
2500 "max": 1,
2501 "modified": {
2502 "sum": 2,
2503 "value": 1,
2504 "average": 2.0,
2505 "min": 1,
2506 "max": 1
2507 }
2508 }
2509 "#
2510 );
2511 },
2512 );
2513 }
2514
2515 // As reported here:
2516 // https://github.com/sebastianbergmann/php-code-coverage/issues/607
2517 // An anonymous class declaration is not considered when computing the Cyclomatic Complexity metric for Java
2518 // Only the complexity of the anonymous class content is considered for the computation
2519 #[test]
2520 fn java_anonymous_class() {
2521 check_metrics::<JavaParser>(
2522 "
2523 abstract class A { // +2 (+1 unit space)
2524 public abstract boolean m1(int n); // +1
2525 public abstract boolean m2(int n); // +1
2526 }
2527 public class B { // +1
2528 public void test() { // +1
2529 A a = new A() {
2530 public boolean m1(int n) { // +1
2531 if (n % 2 == 0) { // +1
2532 return true;
2533 }
2534 return false;
2535 }
2536 public boolean m2(int n) { // +1
2537 if (n % 5 == 0) { // +1
2538 return true;
2539 }
2540 return false;
2541 }
2542 };
2543 }
2544 }",
2545 "foo.java",
2546 |metric| {
2547 // nspace = 9: unit, the two named classes (A, B), the
2548 // anonymous class (`new A() { ... }`, now its own Class
2549 // space — #463), and 5 methods. The anonymous class adds a
2550 // base +1 to the cyclomatic sum exactly like a named class,
2551 // so the file-level sum is 11 (was 10 before the anonymous
2552 // body opened its own space). Each method's complexity is
2553 // counted once — the +1 is the new class space, not a
2554 // re-count of any method.
2555 insta::assert_json_snapshot!(
2556 metric.cyclomatic,
2557 @r#"
2558 {
2559 "sum": 11,
2560 "value": 1,
2561 "average": 2.2,
2562 "min": 1,
2563 "max": 2,
2564 "modified": {
2565 "sum": 11,
2566 "value": 1,
2567 "average": 2.2,
2568 "min": 1,
2569 "max": 2
2570 }
2571 }
2572 "#
2573 );
2574 },
2575 );
2576 }
2577
2578 /// Java `do { … } while (…)` contributes exactly +1 to both
2579 /// standard and modified CCN. The +1 comes from the `while`
2580 /// keyword token (`Java::While`) inside the do-statement, which
2581 /// the dedicated `JavaCode` impl already counts. Adding
2582 /// `Java::DoStatement` would double-count — see issue #284. This
2583 /// test pins the correct keyword-driven count.
2584 #[test]
2585 fn java_do_statement_counts_in_cyclomatic() {
2586 check_metrics::<JavaParser>(
2587 "class Parity {
2588 static void f() {
2589 int i = 0;
2590 do { // +1 (via inner `while` keyword)
2591 ++i;
2592 } while (i < 10);
2593 }
2594 }",
2595 "foo.java",
2596 |metric| {
2597 // standard: unit(1) + class(1) + method(1) + do(1) = 4
2598 let s = &metric.cyclomatic;
2599 assert_eq!(s.cyclomatic_sum(), 4);
2600 assert_eq!(s.cyclomatic_max(), 2);
2601 assert_eq!(s.cyclomatic_modified_sum(), 4);
2602 insta::assert_json_snapshot!(
2603 metric.cyclomatic,
2604 @r#"
2605 {
2606 "sum": 4,
2607 "value": 1,
2608 "average": 4.0,
2609 "min": 1,
2610 "max": 2,
2611 "modified": {
2612 "sum": 4,
2613 "value": 1,
2614 "average": 4.0,
2615 "min": 1,
2616 "max": 2
2617 }
2618 }
2619 "#
2620 );
2621 },
2622 );
2623 }
2624
2625 /// Java enhanced-for `for (T x : xs)` contributes exactly +1 to
2626 /// both standard and modified CCN — the `for` keyword token
2627 /// (`Java::For`) fires inside the `EnhancedForStatement` node
2628 /// just like inside a classic `ForStatement`. Pinning this
2629 /// prevents reintroducing the double-count from issue #284's
2630 /// incorrect fix proposal.
2631 #[test]
2632 fn java_enhanced_for_statement_counts_in_cyclomatic() {
2633 check_metrics::<JavaParser>(
2634 "class Parity {
2635 static void f(int[] xs) {
2636 for (int x : xs) { // +1 (via `for` keyword)
2637 g(x);
2638 }
2639 }
2640 }",
2641 "foo.java",
2642 |metric| {
2643 // standard: unit(1) + class(1) + method(1) + enhanced-for(1) = 4
2644 let s = &metric.cyclomatic;
2645 assert_eq!(s.cyclomatic_sum(), 4);
2646 assert_eq!(s.cyclomatic_max(), 2);
2647 assert_eq!(s.cyclomatic_modified_sum(), 4);
2648 insta::assert_json_snapshot!(
2649 metric.cyclomatic,
2650 @r#"
2651 {
2652 "sum": 4,
2653 "value": 1,
2654 "average": 4.0,
2655 "min": 1,
2656 "max": 2,
2657 "modified": {
2658 "sum": 4,
2659 "value": 1,
2660 "average": 4.0,
2661 "min": 1,
2662 "max": 2
2663 }
2664 }
2665 "#
2666 );
2667 },
2668 );
2669 }
2670
2671 #[test]
2672 fn groovy_simple_class() {
2673 check_metrics::<GroovyParser>(
2674 "
2675 class Example {
2676 int a = 10
2677 boolean b = (a > 5) ? true : false
2678 boolean c = b && true
2679
2680 void m1() {
2681 if (a % 2 == 0) {
2682 b = b || c
2683 }
2684 }
2685 void m2() {
2686 while (a > 3) {
2687 m1()
2688 a--
2689 }
2690 }
2691 }",
2692 "foo.groovy",
2693 |metric| {
2694 // Same shape as `java_simple_class`. nspace = 4
2695 // (unit, class, 2 methods); branches mirror Java's.
2696 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 9);
2697 },
2698 );
2699 }
2700
2701 #[test]
2702 fn groovy_nested_control_flow() {
2703 check_metrics::<GroovyParser>(
2704 "void f(int x) {
2705 if (x > 0) {
2706 while (x < 100) {
2707 x = x + 1
2708 }
2709 }
2710 }",
2711 "foo.groovy",
2712 |metric| {
2713 // unit(1) + fn(1) + if(1) + while(1) = 4
2714 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
2715 },
2716 );
2717 }
2718
2719 #[test]
2720 fn groovy_switch_with_cases() {
2721 check_metrics::<GroovyParser>(
2722 "void print(int result) {
2723 switch (result) {
2724 case -1:
2725 println 'minus one'
2726 break
2727 case -2:
2728 println 'minus two'
2729 break
2730 default:
2731 println 'other'
2732 }
2733 }",
2734 "foo.groovy",
2735 |metric| {
2736 // standard: unit(1) + fn(1) + 2 cases = 4
2737 // modified: unit(1) + fn(1) + switch(1) = 3
2738 // (default does NOT add a branch — same as Java/lesson #106)
2739 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
2740 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
2741 },
2742 );
2743 }
2744
2745 #[test]
2746 fn groovy_try_catch() {
2747 check_metrics::<GroovyParser>(
2748 "void f() {
2749 try {
2750 risky()
2751 } catch (Exception e) {
2752 handle(e)
2753 }
2754 }",
2755 "foo.groovy",
2756 |metric| {
2757 // unit(1) + fn(1) + catch(1) = 3
2758 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
2759 },
2760 );
2761 }
2762
2763 #[test]
2764 fn groovy_closure_body_short_circuit() {
2765 // Top-level `def pred = { … }` collapses the closure into the
2766 // unit scope (no class wrapper), so the `&&` inside still
2767 // contributes one branch but no extra function space is
2768 // created. Mirrors Java's top-level-lambda behavior.
2769 check_metrics::<GroovyParser>(
2770 "def pred = { x -> x > 0 && x < 100 }",
2771 "foo.groovy",
2772 |metric| {
2773 // unit(1) + && (1) = 2
2774 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 2);
2775 },
2776 );
2777 }
2778
2779 #[test]
2780 fn groovy_assert_adds_branch() {
2781 // Groovy `assert` is a runtime check that branches on its
2782 // condition; mirror Sonar's standard-CCN treatment.
2783 check_metrics::<GroovyParser>(
2784 "void check(int x) {
2785 assert x > 0
2786 }",
2787 "foo.groovy",
2788 |metric| {
2789 // unit(1) + fn(1) + assert(1) = 3
2790 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
2791 },
2792 );
2793 }
2794
2795 /// Groovy `do { … } while (…)` contributes exactly +1 to both
2796 /// standard and modified CCN — the `while` keyword token
2797 /// (`Groovy::While`) inside the do-statement is already counted
2798 /// by the dedicated `GroovyCode` impl. Adding `Groovy::DoStatement`
2799 /// would double-count (issue #284). This test pins the correct
2800 /// keyword-driven count.
2801 #[test]
2802 fn groovy_do_statement_counts_in_cyclomatic() {
2803 check_metrics::<GroovyParser>(
2804 "def f() {
2805 int i = 0
2806 do { // +1 (via inner `while` keyword)
2807 ++i
2808 } while (i < 10)
2809 }",
2810 "foo.groovy",
2811 |metric| {
2812 // standard: unit(1) + fn(1) + do(1) = 3
2813 let s = &metric.cyclomatic;
2814 assert_eq!(s.cyclomatic_sum(), 3);
2815 assert_eq!(s.cyclomatic_max(), 2);
2816 assert_eq!(s.cyclomatic_modified_sum(), 3);
2817 },
2818 );
2819 }
2820
2821 /// Groovy enhanced-for `for (T x : xs)` contributes exactly +1 to
2822 /// both standard and modified CCN — the `for` keyword token
2823 /// (`Groovy::For`) fires inside `EnhancedForStatement` just like
2824 /// inside a classic `ForStatement`. Pinning this prevents
2825 /// reintroducing the double-count from issue #284's incorrect fix
2826 /// proposal.
2827 #[test]
2828 fn groovy_enhanced_for_statement_counts_in_cyclomatic() {
2829 check_metrics::<GroovyParser>(
2830 "def f(int[] xs) {
2831 for (int x : xs) { // +1 (via `for` keyword)
2832 println(x)
2833 }
2834 }",
2835 "foo.groovy",
2836 |metric| {
2837 // standard: unit(1) + fn(1) + enhanced-for(1) = 3
2838 let s = &metric.cyclomatic;
2839 assert_eq!(s.cyclomatic_sum(), 3);
2840 assert_eq!(s.cyclomatic_max(), 2);
2841 assert_eq!(s.cyclomatic_modified_sum(), 3);
2842 },
2843 );
2844 }
2845
2846 #[test]
2847 fn groovy_safe_navigation_cyclomatic() {
2848 // Issue #452: Groovy's safe-navigation `?.` (QMARKDOT) is a
2849 // short-circuit decision point per link, mirroring the
2850 // Kotlin/PHP/JS/C# treatment of `?.` (#281). The chain
2851 // `a?.b?.c` adds +2 to both standard and modified CCN.
2852 check_metrics::<GroovyParser>("def read(a){ return a?.b?.c }", "foo.groovy", |metric| {
2853 // unit(1) + fn(base 1 + ?. 1 + ?. 1) = sum 4, max 3.
2854 let s = &metric.cyclomatic;
2855 assert_eq!(s.cyclomatic_sum(), 4);
2856 assert_eq!(s.cyclomatic_max(), 3);
2857 assert_eq!(s.cyclomatic_modified_sum(), 4);
2858 assert_eq!(s.cyclomatic_modified_max(), 3);
2859 });
2860 }
2861
2862 #[test]
2863 fn groovy_safe_chain_dot_cyclomatic() {
2864 // Issue #452: Groovy's `??.` (QMARKQMARKDOT, the spread-safe
2865 // chain-dot operator) is also a short-circuit decision point,
2866 // counted once per occurrence like `?.`.
2867 check_metrics::<GroovyParser>("def read(a){ return a??.b }", "foo.groovy", |metric| {
2868 // unit(1) + fn(base 1 + ??. 1) = sum 3, max 2.
2869 let s = &metric.cyclomatic;
2870 assert_eq!(s.cyclomatic_sum(), 3);
2871 assert_eq!(s.cyclomatic_max(), 2);
2872 assert_eq!(s.cyclomatic_modified_sum(), 3);
2873 assert_eq!(s.cyclomatic_modified_max(), 2);
2874 });
2875 }
2876
2877 #[test]
2878 fn perl_nested_control_flow() {
2879 check_metrics::<PerlParser>(
2880 "sub f { # +1 (unit) +1 (sub)
2881 for my $i (1..10) { # +1 for_statement_2
2882 if ($i % 2) { # +1 if_statement
2883 print $i;
2884 }
2885 }
2886 }",
2887 "foo.pl",
2888 |metric| {
2889 insta::assert_json_snapshot!(
2890 metric.cyclomatic,
2891 @r#"
2892 {
2893 "sum": 4,
2894 "value": 1,
2895 "average": 4.0,
2896 "min": 1,
2897 "max": 3,
2898 "modified": {
2899 "sum": 4,
2900 "value": 1,
2901 "average": 4.0,
2902 "min": 1,
2903 "max": 3
2904 }
2905 }
2906 "#
2907 );
2908 },
2909 );
2910 }
2911
2912 #[test]
2913 fn perl_postfix_conditionals() {
2914 check_metrics::<PerlParser>(
2915 "sub f { # +1 (unit) +1 (sub)
2916 return 1 if $_[0]; # +1 if_simple_statement
2917 return 0 unless $_[1]; # +1 unless_simple_statement
2918 }",
2919 "foo.pl",
2920 |metric| {
2921 insta::assert_json_snapshot!(
2922 metric.cyclomatic,
2923 @r#"
2924 {
2925 "sum": 4,
2926 "value": 1,
2927 "average": 4.0,
2928 "min": 1,
2929 "max": 3,
2930 "modified": {
2931 "sum": 4,
2932 "value": 1,
2933 "average": 4.0,
2934 "min": 1,
2935 "max": 3
2936 }
2937 }
2938 "#
2939 );
2940 },
2941 );
2942 }
2943
2944 #[test]
2945 fn perl_unless_and_until() {
2946 check_metrics::<PerlParser>(
2947 "sub f { # +1 (unit) +1 (sub)
2948 unless ($x) { # +1 unless_statement
2949 print 'a';
2950 }
2951 until ($n == 0) { # +1 until_statement
2952 $n--;
2953 }
2954 }",
2955 "foo.pl",
2956 |metric| {
2957 insta::assert_json_snapshot!(
2958 metric.cyclomatic,
2959 @r#"
2960 {
2961 "sum": 4,
2962 "value": 1,
2963 "average": 4.0,
2964 "min": 1,
2965 "max": 3,
2966 "modified": {
2967 "sum": 4,
2968 "value": 1,
2969 "average": 4.0,
2970 "min": 1,
2971 "max": 3
2972 }
2973 }
2974 "#
2975 );
2976 },
2977 );
2978 }
2979
2980 #[test]
2981 fn perl_logical_operators_and_ternary() {
2982 check_metrics::<PerlParser>(
2983 "sub f { # +1 (unit) +1 (sub)
2984 my $x = $a && $b; # +1 (&&)
2985 my $y = $c || $d; # +1 (||)
2986 my $z = $e // $f; # +1 (//)
2987 my $t = $g ? 1 : 0; # +1 ternary
2988 }",
2989 "foo.pl",
2990 |metric| {
2991 insta::assert_json_snapshot!(
2992 metric.cyclomatic,
2993 @r#"
2994 {
2995 "sum": 6,
2996 "value": 1,
2997 "average": 6.0,
2998 "min": 1,
2999 "max": 5,
3000 "modified": {
3001 "sum": 6,
3002 "value": 1,
3003 "average": 6.0,
3004 "min": 1,
3005 "max": 5
3006 }
3007 }
3008 "#
3009 );
3010 },
3011 );
3012 }
3013
3014 #[test]
3015 fn perl_word_logical_operators() {
3016 check_metrics::<PerlParser>(
3017 "sub f { # +1 (unit) +1 (sub)
3018 my $x = $a and $b; # +1 (and)
3019 my $y = $c or $d; # +1 (or)
3020 }",
3021 "foo.pl",
3022 |metric| {
3023 insta::assert_json_snapshot!(
3024 metric.cyclomatic,
3025 @r#"
3026 {
3027 "sum": 4,
3028 "value": 1,
3029 "average": 4.0,
3030 "min": 1,
3031 "max": 3,
3032 "modified": {
3033 "sum": 4,
3034 "value": 1,
3035 "average": 4.0,
3036 "min": 1,
3037 "max": 3
3038 }
3039 }
3040 "#
3041 );
3042 },
3043 );
3044 }
3045
3046 #[test]
3047 fn perl_compound_short_circuit_assignment_249() {
3048 // Regression for issue #249: `&&=`, `||=`, `//=` are each one
3049 // short-circuit decision edge — semantically `$x = $x op $y`.
3050 // Perl exposes the operator token inside `binary_expression`,
3051 // so adding the three `*EQ` tokens to the cyclomatic arm picks
3052 // them up alongside the bare `&&` / `||` / `//`.
3053 check_metrics::<PerlParser>(
3054 "sub f { # +1 (unit) +1 (sub)
3055 my ($x, $y, $z) = @_;
3056 $x ||= 1; # +1 (||=)
3057 $y &&= 2; # +1 (&&=)
3058 $z //= 3; # +1 (//=)
3059 return $x;
3060 }",
3061 "foo.pl",
3062 |metric| {
3063 // unit(1) + fn(entry 1 + 3 assignments = 4) = sum 5, max 4.
3064 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
3065 assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
3066 insta::assert_json_snapshot!(
3067 metric.cyclomatic,
3068 @r#"
3069 {
3070 "sum": 5,
3071 "value": 1,
3072 "average": 5.0,
3073 "min": 1,
3074 "max": 4,
3075 "modified": {
3076 "sum": 5,
3077 "value": 1,
3078 "average": 5.0,
3079 "min": 1,
3080 "max": 4
3081 }
3082 }
3083 "#
3084 );
3085 },
3086 );
3087 }
3088
3089 #[test]
3090 fn perl_foreach_loop() {
3091 check_metrics::<PerlParser>(
3092 "sub f { # +1 (unit) +1 (sub)
3093 foreach my $i (@list) { # +1 for_statement_2
3094 print $i;
3095 }
3096 }",
3097 "foo.pl",
3098 |metric| {
3099 insta::assert_json_snapshot!(metric.cyclomatic, @r#"
3100 {
3101 "sum": 3,
3102 "value": 1,
3103 "average": 3.0,
3104 "min": 1,
3105 "max": 2,
3106 "modified": {
3107 "sum": 3,
3108 "value": 1,
3109 "average": 3.0,
3110 "min": 1,
3111 "max": 2
3112 }
3113 }
3114 "#);
3115 },
3116 );
3117 }
3118
3119 #[test]
3120 fn perl_else_does_not_count_but_elsif_does() {
3121 check_metrics::<PerlParser>(
3122 "sub f { # +1 (unit) +1 (sub)
3123 if ($x) { # +1 if_statement
3124 print 'a';
3125 } elsif ($y) { # +1 elsif_clause
3126 print 'b';
3127 } else {
3128 print 'c';
3129 }
3130 }",
3131 "foo.pl",
3132 |metric| {
3133 insta::assert_json_snapshot!(
3134 metric.cyclomatic,
3135 @r#"
3136 {
3137 "sum": 4,
3138 "value": 1,
3139 "average": 4.0,
3140 "min": 1,
3141 "max": 3,
3142 "modified": {
3143 "sum": 4,
3144 "value": 1,
3145 "average": 4.0,
3146 "min": 1,
3147 "max": 3
3148 }
3149 }
3150 "#
3151 );
3152 },
3153 );
3154 }
3155
3156 #[test]
3157 fn tsx_simple_function() {
3158 check_metrics::<TsxParser>(
3159 "function f(a: number, b: number) { // +2 (+1 unit space)
3160 if (a > 0) { // +1
3161 return a;
3162 } else if (b > 0) { // +1
3163 return b;
3164 }
3165 return 0;
3166 }",
3167 "foo.tsx",
3168 |metric| {
3169 insta::assert_json_snapshot!(
3170 metric.cyclomatic,
3171 @r#"
3172 {
3173 "sum": 4,
3174 "value": 1,
3175 "average": 4.0,
3176 "min": 1,
3177 "max": 3,
3178 "modified": {
3179 "sum": 4,
3180 "value": 1,
3181 "average": 4.0,
3182 "min": 1,
3183 "max": 3
3184 }
3185 }
3186 "#
3187 );
3188 },
3189 );
3190 }
3191
3192 #[test]
3193 fn typescript_if_else_and_switch() {
3194 check_metrics::<TypescriptParser>(
3195 "function classify(value: number): string {
3196 if (value < 0) { // +1
3197 return 'negative';
3198 } else if (value === 0) { // +1
3199 return 'zero';
3200 }
3201 switch (value) {
3202 case 1: // +1
3203 return 'one';
3204 case 2: // +1
3205 return 'two';
3206 default:
3207 return 'other';
3208 }
3209 }",
3210 "foo.ts",
3211 |metric| {
3212 insta::assert_json_snapshot!(
3213 metric.cyclomatic,
3214 @r#"
3215 {
3216 "sum": 6,
3217 "value": 1,
3218 "average": 6.0,
3219 "min": 1,
3220 "max": 5,
3221 "modified": {
3222 "sum": 5,
3223 "value": 1,
3224 "average": 5.0,
3225 "min": 1,
3226 "max": 4
3227 }
3228 }
3229 "#
3230 );
3231 },
3232 );
3233 }
3234
3235 /// Modified CCN: TypeScript switch with 3 cases collapses to 1.
3236 #[test]
3237 fn typescript_switch_modified() {
3238 check_metrics::<TypescriptParser>(
3239 "function f(x: number): string {
3240 switch (x) {
3241 case 1: return 'one';
3242 case 2: return 'two';
3243 case 3: return 'three';
3244 default: return 'other';
3245 }
3246 }",
3247 "foo.ts",
3248 |metric| {
3249 // standard: unit(1) + fn(1) + 3 cases = 5
3250 // modified: unit(1) + fn(1) + switch(1) = 3
3251 insta::assert_json_snapshot!(
3252 metric.cyclomatic,
3253 @r#"
3254 {
3255 "sum": 5,
3256 "value": 1,
3257 "average": 5.0,
3258 "min": 1,
3259 "max": 4,
3260 "modified": {
3261 "sum": 3,
3262 "value": 1,
3263 "average": 3.0,
3264 "min": 1,
3265 "max": 2
3266 }
3267 }
3268 "#
3269 );
3270 },
3271 );
3272 }
3273
3274 #[test]
3275 fn mozjs_if_else_and_switch() {
3276 check_metrics::<MozjsParser>(
3277 "function f(x) { // +2 (+1 unit space)
3278 if (x > 0) { // +1
3279 return 1;
3280 } else if (x < 0) { // +1
3281 return -1;
3282 }
3283 switch (x) {
3284 case 0: // +1
3285 return 0;
3286 case 42: // +1
3287 return 42;
3288 default:
3289 return -2;
3290 }
3291 }",
3292 "foo.js",
3293 |metric| {
3294 insta::assert_json_snapshot!(
3295 metric.cyclomatic,
3296 @r#"
3297 {
3298 "sum": 6,
3299 "value": 1,
3300 "average": 6.0,
3301 "min": 1,
3302 "max": 5,
3303 "modified": {
3304 "sum": 5,
3305 "value": 1,
3306 "average": 5.0,
3307 "min": 1,
3308 "max": 4
3309 }
3310 }
3311 "#
3312 );
3313 },
3314 );
3315 }
3316
3317 /// Modified CCN: MozJS switch with 2 cases collapses to 1.
3318 #[test]
3319 fn mozjs_switch_modified() {
3320 check_metrics::<MozjsParser>(
3321 "function f(x) {
3322 switch (x) {
3323 case 1: return 1;
3324 case 2: return 2;
3325 }
3326 }",
3327 "foo.js",
3328 |metric| {
3329 // standard: unit(1) + fn(1) + 2 cases = 4
3330 // modified: unit(1) + fn(1) + switch(1) = 3
3331 insta::assert_json_snapshot!(
3332 metric.cyclomatic,
3333 @r#"
3334 {
3335 "sum": 4,
3336 "value": 1,
3337 "average": 4.0,
3338 "min": 1,
3339 "max": 3,
3340 "modified": {
3341 "sum": 3,
3342 "value": 1,
3343 "average": 3.0,
3344 "min": 1,
3345 "max": 2
3346 }
3347 }
3348 "#
3349 );
3350 },
3351 );
3352 }
3353
3354 #[test]
3355 fn kotlin_cyclomatic_mixed() {
3356 check_metrics::<KotlinParser>(
3357 "class Calc {
3358 fun compute(x: Int, y: Int): Int {
3359 if (x > 0) { // +1
3360 for (i in 1..x) { // +1
3361 println(i)
3362 }
3363 }
3364 when (y) {
3365 1 -> println(\"one\") // +1 (WhenEntry)
3366 2 -> println(\"two\") // +1
3367 else -> println(\"?\") // skipped (else is default)
3368 }
3369 val ok = x > 0 && y > 0 // +1
3370 try {
3371 println(x / y)
3372 } catch (e: Exception) { // +1
3373 println(\"err\")
3374 }
3375 return x + y
3376 }
3377 }",
3378 "foo.kt",
3379 |metric| {
3380 // expected: unit(1) + class(1) + fn(base 1 + if 1 + for 1 +
3381 // 2 explicit when arms; else skipped + && 1 +
3382 // catch 1) = sum 9, max 7.
3383 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 9);
3384 assert_eq!(metric.cyclomatic.cyclomatic_max(), 7);
3385 insta::assert_json_snapshot!(
3386 metric.cyclomatic,
3387 @r#"
3388 {
3389 "sum": 9,
3390 "value": 1,
3391 "average": 9.0,
3392 "min": 1,
3393 "max": 7,
3394 "modified": {
3395 "sum": 8,
3396 "value": 1,
3397 "average": 8.0,
3398 "min": 1,
3399 "max": 6
3400 }
3401 }
3402 "#
3403 );
3404 },
3405 );
3406 }
3407
3408 /// Modified CCN: Kotlin when with 3 entries collapses to 1.
3409 #[test]
3410 fn kotlin_when_modified() {
3411 check_metrics::<KotlinParser>(
3412 "fun describe(x: Int): String {
3413 return when (x) {
3414 1 -> \"one\"
3415 2 -> \"two\"
3416 3 -> \"three\"
3417 else -> \"other\"
3418 }
3419 }",
3420 "foo.kt",
3421 |metric| {
3422 // standard: unit(1) + fn(base 1 + 3 explicit when arms;
3423 // else skipped per #282) = 5
3424 // modified: unit(1) + fn(1) + WhenExpression(1) = 3
3425 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
3426 assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
3427 insta::assert_json_snapshot!(
3428 metric.cyclomatic,
3429 @r#"
3430 {
3431 "sum": 5,
3432 "value": 1,
3433 "average": 5.0,
3434 "min": 1,
3435 "max": 4,
3436 "modified": {
3437 "sum": 3,
3438 "value": 1,
3439 "average": 3.0,
3440 "min": 1,
3441 "max": 2
3442 }
3443 }
3444 "#
3445 );
3446 },
3447 );
3448 }
3449
3450 /// Regression #282: the `else -> …` arm in a Kotlin `when`
3451 /// expression must NOT contribute to standard CCN, mirroring the
3452 /// C-family `default:` rule.
3453 #[test]
3454 fn kotlin_when_else_arm_not_counted() {
3455 check_metrics::<KotlinParser>(
3456 "fun describe(x: Int): String {
3457 return when (x) {
3458 1 -> \"one\"
3459 else -> \"other\"
3460 }
3461 }",
3462 "foo.kt",
3463 |metric| {
3464 // expected: unit(1) + fn(base 1 + 1 explicit; else skipped) = 3, max 2.
3465 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
3466 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
3467 },
3468 );
3469 }
3470
3471 /// Cross-check #282: every case-style arm in a Kotlin `when`
3472 /// contributes one standard decision; only the `else ->` arm is
3473 /// skipped. Pairs with `kotlin_when_else_arm_not_counted` (which
3474 /// pins the single-explicit case) to confirm the count scales
3475 /// linearly with explicit arms and is not accidentally hard-coded
3476 /// to one.
3477 #[test]
3478 fn kotlin_when_multiple_explicit_arms_each_count() {
3479 check_metrics::<KotlinParser>(
3480 "fun describe(x: Int): String {
3481 return when (x) {
3482 1 -> \"one\"
3483 2 -> \"two\"
3484 3 -> \"three\"
3485 else -> \"other\"
3486 }
3487 }",
3488 "foo.kt",
3489 |metric| {
3490 // expected: unit(1) + fn(base 1 + 3 explicit; else skipped) = 5, max 4.
3491 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
3492 assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
3493 },
3494 );
3495 }
3496
3497 #[test]
3498 fn lua_1_level_nesting() {
3499 // chunk: base=1; f: base=1 + for=1 + if=1 = 3; sum=4
3500 check_metrics::<LuaParser>(
3501 "local function f(t)
3502 for i = 1, #t do
3503 if t[i] > 0 then
3504 return t[i]
3505 end
3506 end
3507 return 0
3508end",
3509 "foo.lua",
3510 |metric| {
3511 insta::assert_json_snapshot!(metric.cyclomatic, @r#"
3512 {
3513 "sum": 4,
3514 "value": 1,
3515 "average": 4.0,
3516 "min": 1,
3517 "max": 3,
3518 "modified": {
3519 "sum": 4,
3520 "value": 1,
3521 "average": 4.0,
3522 "min": 1,
3523 "max": 3
3524 }
3525 }
3526 "#);
3527 },
3528 );
3529 }
3530
3531 #[test]
3532 fn lua_elseif_branches() {
3533 // chunk: base=1; classify: base=1 + if=1 + elseif=1 + elseif=1 = 4
3534 // else does NOT add a branch; sum=5
3535 check_metrics::<LuaParser>(
3536 "local function classify(x)
3537 if x > 0 then
3538 return 1
3539 elseif x < 0 then
3540 return -1
3541 elseif x == 0 then
3542 return 0
3543 else
3544 return 0
3545 end
3546end",
3547 "foo.lua",
3548 |metric| {
3549 insta::assert_json_snapshot!(metric.cyclomatic, @r#"
3550 {
3551 "sum": 5,
3552 "value": 1,
3553 "average": 5.0,
3554 "min": 1,
3555 "max": 4,
3556 "modified": {
3557 "sum": 5,
3558 "value": 1,
3559 "average": 5.0,
3560 "min": 1,
3561 "max": 4
3562 }
3563 }
3564 "#);
3565 },
3566 );
3567 }
3568
3569 #[test]
3570 fn lua_logical_operators() {
3571 // chunk: base=1; f: base=1 + if=1 + and=1 + or=1 = 4; sum=5
3572 check_metrics::<LuaParser>(
3573 "local function f(a, b, c)
3574 if a and b or c then
3575 return 1
3576 end
3577 return 0
3578end",
3579 "foo.lua",
3580 |metric| {
3581 insta::assert_json_snapshot!(metric.cyclomatic, @r#"
3582 {
3583 "sum": 5,
3584 "value": 1,
3585 "average": 5.0,
3586 "min": 1,
3587 "max": 4,
3588 "modified": {
3589 "sum": 5,
3590 "value": 1,
3591 "average": 5.0,
3592 "min": 1,
3593 "max": 4
3594 }
3595 }
3596 "#);
3597 },
3598 );
3599 }
3600
3601 #[test]
3602 fn bash_nested_control_flow() {
3603 check_metrics::<BashParser>(
3604 "#!/bin/bash
3605f() {
3606 if [ $1 -eq 1 ]; then
3607 for i in 1 2 3; do
3608 echo $i
3609 done
3610 elif [ $1 -eq 2 ]; then
3611 echo 'two'
3612 fi
3613}",
3614 "foo.sh",
3615 |metric| {
3616 insta::assert_json_snapshot!(
3617 metric.cyclomatic,
3618 {".sum" => insta::rounded_redaction(2)}
3619 );
3620 },
3621 );
3622 }
3623
3624 /// Regression test for #107: case…esac must not double-count the container.
3625 /// Standard CCN counts only arms (matching C-family `switch` semantics).
3626 /// Modified CCN counts only the container.
3627 #[test]
3628 fn bash_case_modified() {
3629 check_metrics::<BashParser>(
3630 "#!/bin/bash
3631f() {
3632 case $1 in
3633 one) echo 1 ;;
3634 two) echo 2 ;;
3635 three) echo 3 ;;
3636 esac
3637}",
3638 "foo.sh",
3639 |metric| {
3640 // standard: unit(1) + fn(1) + 3 case_items = 5
3641 // modified: unit(1) + fn(1) + case_stmt(1) = 3
3642 insta::assert_json_snapshot!(
3643 metric.cyclomatic,
3644 @r#"
3645 {
3646 "sum": 5,
3647 "value": 1,
3648 "average": 5.0,
3649 "min": 1,
3650 "max": 4,
3651 "modified": {
3652 "sum": 3,
3653 "value": 1,
3654 "average": 3.0,
3655 "min": 1,
3656 "max": 2
3657 }
3658 }
3659 "#
3660 );
3661 },
3662 );
3663 }
3664
3665 #[test]
3666 fn tcl_1_level_nesting() {
3667 // chunk: base=1; f: base=1 + while=1 + if=1 = 3; sum=4
3668 check_metrics::<TclParser>(
3669 "proc f {x} {
3670 while {$x > 0} {
3671 if {$x > 10} {
3672 set x [expr {$x - 1}]
3673 }
3674 }
3675}",
3676 "foo.tcl",
3677 |metric| {
3678 // unit(1) + proc(base 1 + while 1 + if 1) = sum 4, max 3.
3679 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
3680 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
3681 insta::assert_json_snapshot!(metric.cyclomatic);
3682 },
3683 );
3684 }
3685
3686 #[test]
3687 fn tcl_elseif_branch() {
3688 // if=1, elseif=1; else does NOT add a branch; sum=3 (chunk base=1)
3689 check_metrics::<TclParser>(
3690 "proc f {x} {
3691 if {$x > 10} {
3692 puts big
3693 } elseif {$x > 5} {
3694 puts medium
3695 } else {
3696 puts small
3697 }
3698}",
3699 "foo.tcl",
3700 |metric| {
3701 // unit(1) + proc(base 1 + if 1 + elseif 1) = sum 4, max 3.
3702 // else does NOT add a branch.
3703 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
3704 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
3705 insta::assert_json_snapshot!(metric.cyclomatic);
3706 },
3707 );
3708 }
3709
3710 #[test]
3711 fn tcl_logical_operators() {
3712 check_metrics::<TclParser>(
3713 "proc f {x y z} {
3714 if {$x > 0 && $y > 0 || $z > 0} {
3715 puts ok
3716 }
3717}",
3718 "foo.tcl",
3719 |metric| {
3720 // unit(1) + proc(base 1 + if 1 + && 1 + || 1) = sum 5, max 4.
3721 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
3722 assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
3723 insta::assert_json_snapshot!(metric.cyclomatic);
3724 },
3725 );
3726 }
3727
3728 #[test]
3729 fn tcl_catch_branch() {
3730 // `catch` command adds +1 (conditional handler); `try` does NOT add a branch.
3731 // source_file(1) + proc_space(base=1 + catch=1 = 2) = sum=3
3732 check_metrics::<TclParser>(
3733 "proc f {} {
3734 catch {
3735 expr {1 / 0}
3736 } msg
3737}",
3738 "foo.tcl",
3739 |metric| {
3740 // unit(1) + proc(base 1 + catch 1) = sum 3, max 2.
3741 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
3742 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
3743 insta::assert_json_snapshot!(metric.cyclomatic);
3744 },
3745 );
3746 }
3747
3748 #[test]
3749 fn tcl_try_no_branch() {
3750 // `try` is NOT a conditional construct; it does not add cyclomatic complexity.
3751 // Only the base counts: source_file(1) + proc_space(base=1) = sum=2, average=1.
3752 check_metrics::<TclParser>(
3753 "proc f {} {
3754 try {
3755 expr {1 / 0}
3756 } finally {
3757 puts done
3758 }
3759}",
3760 "foo.tcl",
3761 |metric| {
3762 insta::assert_json_snapshot!(
3763 metric.cyclomatic,
3764 @r#"
3765 {
3766 "sum": 2,
3767 "value": 1,
3768 "average": 2.0,
3769 "min": 1,
3770 "max": 1,
3771 "modified": {
3772 "sum": 2,
3773 "value": 1,
3774 "average": 2.0,
3775 "min": 1,
3776 "max": 1
3777 }
3778 }
3779 "#
3780 );
3781 },
3782 );
3783 }
3784
3785 #[test]
3786 fn tcl_switch_cyclomatic() {
3787 // Tcl `switch` is a generic command; each non-`default` arm is a
3788 // decision point in standard CCN, while modified CCN counts the
3789 // construct once (issue #467). Three arms (1, 2, default): the two
3790 // non-default arms add +2 standard; `default` is free.
3791 check_metrics::<TclParser>(
3792 "proc f {x} {
3793 switch $x {
3794 1 { puts a }
3795 2 { puts b }
3796 default { puts c }
3797 }
3798}",
3799 "foo.tcl",
3800 |metric| {
3801 // unit(1) + proc(base 1 + arm 1 + arm 2) = standard sum 4, max 3.
3802 // modified collapses arms to one container: unit(1) +
3803 // proc(base 1 + switch 1) = sum 3, max 2.
3804 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
3805 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
3806 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
3807 assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 2);
3808 },
3809 );
3810 }
3811
3812 #[test]
3813 fn tcl_switch_cyclomatic_no_default_with_options() {
3814 // No `default` arm, and leading `switch` options (`-exact --`) precede
3815 // the value: the arm list is still the trailing braced word, so both
3816 // arms count. Guards the option-form arm-list location (issue #467).
3817 check_metrics::<TclParser>(
3818 "proc f {x} {
3819 switch -exact -- $x {
3820 1 { puts a }
3821 2 { puts b }
3822 }
3823}",
3824 "foo.tcl",
3825 |metric| {
3826 // unit(1) + proc(base 1 + arm 1 + arm 2) = standard sum 4, max 3.
3827 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
3828 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
3829 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
3830 assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 2);
3831 },
3832 );
3833 }
3834
3835 #[test]
3836 fn mozjs_for_loop() {
3837 check_metrics::<MozjsParser>(
3838 "function f(n) { // +2 (+1 unit)
3839 var s = 0;
3840 for (var i = 0; i < n; i++) { // +1
3841 s += i;
3842 }
3843 return s;
3844 }",
3845 "foo.js",
3846 |metric| {
3847 // unit(1) + fn(base 1 + for 1) = sum 3, max 2.
3848 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
3849 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
3850 insta::assert_json_snapshot!(metric.cyclomatic);
3851 },
3852 );
3853 }
3854
3855 #[test]
3856 fn mozjs_logical_operators() {
3857 check_metrics::<MozjsParser>(
3858 "function f(a, b, c) { // +2 (+1 unit)
3859 if (a && b || c) { // +1 if, +1 &&, +1 ||
3860 return 1;
3861 }
3862 return 0;
3863 }",
3864 "foo.js",
3865 |metric| {
3866 // unit(1) + fn(base 1 + if 1 + && 1 + || 1) = sum 5, max 4.
3867 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
3868 assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
3869 insta::assert_json_snapshot!(metric.cyclomatic);
3870 },
3871 );
3872 }
3873
3874 #[test]
3875 fn javascript_nullish_coalescing_chain_226() {
3876 // `??` is short-circuit and must count as
3877 // a decision point in cyclomatic complexity. `a ?? b ?? c` adds two
3878 // `??` decisions on top of the function entry.
3879 check_metrics::<JavascriptParser>(
3880 "function pick(a, b, c) { // +1 (entry)
3881 return a ?? b ?? c; // +2 (two `??`)
3882 }",
3883 "foo.js",
3884 |metric| {
3885 // unit(1) + fn(entry 1 + 2*?? = 3) = sum 4, max 3.
3886 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
3887 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
3888 insta::assert_json_snapshot!(
3889 metric.cyclomatic,
3890 @r#"
3891 {
3892 "sum": 4,
3893 "value": 1,
3894 "average": 4.0,
3895 "min": 1,
3896 "max": 3,
3897 "modified": {
3898 "sum": 4,
3899 "value": 1,
3900 "average": 4.0,
3901 "min": 1,
3902 "max": 3
3903 }
3904 }
3905 "#
3906 );
3907 },
3908 );
3909 }
3910
3911 #[test]
3912 fn typescript_nullish_coalescing_with_if_226() {
3913 // TypeScript must count `??` as a
3914 // decision. This mirrors the example in the issue body.
3915 check_metrics::<TypescriptParser>(
3916 "function classify(x: string | null, fallback: string | null): string { // +1 (entry)
3917 if (x === \"y\") return \"yes\"; // +1 (if)
3918 return x ?? fallback ?? \"unknown\"; // +2 (two `??`)
3919 }",
3920 "foo.ts",
3921 |metric| {
3922 // unit(1) + fn(entry 1 + if 1 + 2*?? = 4) = sum 5, max 4.
3923 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
3924 assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
3925 insta::assert_json_snapshot!(
3926 metric.cyclomatic,
3927 @r#"
3928 {
3929 "sum": 5,
3930 "value": 1,
3931 "average": 5.0,
3932 "min": 1,
3933 "max": 4,
3934 "modified": {
3935 "sum": 5,
3936 "value": 1,
3937 "average": 5.0,
3938 "min": 1,
3939 "max": 4
3940 }
3941 }
3942 "#
3943 );
3944 },
3945 );
3946 }
3947
3948 #[test]
3949 fn tsx_nullish_coalescing_chain_226() {
3950 // TSX must count `??` the same as JS/TS.
3951 check_metrics::<TsxParser>(
3952 "function pick(a: number | null, b: number | null, c: number): number { // +1 (entry)
3953 return a ?? b ?? c; // +2 (two `??`)
3954 }",
3955 "foo.tsx",
3956 |metric| {
3957 // unit(1) + fn(entry 1 + 2*?? = 3) = sum 4, max 3.
3958 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
3959 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
3960 insta::assert_json_snapshot!(
3961 metric.cyclomatic,
3962 @r#"
3963 {
3964 "sum": 4,
3965 "value": 1,
3966 "average": 4.0,
3967 "min": 1,
3968 "max": 3,
3969 "modified": {
3970 "sum": 4,
3971 "value": 1,
3972 "average": 4.0,
3973 "min": 1,
3974 "max": 3
3975 }
3976 }
3977 "#
3978 );
3979 },
3980 );
3981 }
3982
3983 #[test]
3984 fn mozjs_nullish_coalescing_chain_226() {
3985 // Mozjs must count `??` the same as JS.
3986 check_metrics::<MozjsParser>(
3987 "function pick(a, b, c) { // +1 (entry)
3988 return a ?? b ?? c; // +2 (two `??`)
3989 }",
3990 "foo.js",
3991 |metric| {
3992 // unit(1) + fn(entry 1 + 2*?? = 3) = sum 4, max 3.
3993 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
3994 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
3995 insta::assert_json_snapshot!(
3996 metric.cyclomatic,
3997 @r#"
3998 {
3999 "sum": 4,
4000 "value": 1,
4001 "average": 4.0,
4002 "min": 1,
4003 "max": 3,
4004 "modified": {
4005 "sum": 4,
4006 "value": 1,
4007 "average": 4.0,
4008 "min": 1,
4009 "max": 3
4010 }
4011 }
4012 "#
4013 );
4014 },
4015 );
4016 }
4017
4018 #[test]
4019 fn javascript_nullish_coalescing_assignment_231() {
4020 // `x ??= y` is `x = x ?? y` — one short-circuit decision edge,
4021 // same as `??`. Two `??=` assignments add +2 on top of the entry.
4022 check_metrics::<JavascriptParser>(
4023 "function pick(o) { // +1 (entry)
4024 o.x ??= 1; // +1 (??=)
4025 o.y ??= 2; // +1 (??=)
4026 return o;
4027 }",
4028 "foo.js",
4029 |metric| {
4030 // unit(1) + fn(entry 1 + 2*??= = 3) = sum 4, max 3.
4031 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4032 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4033 insta::assert_json_snapshot!(
4034 metric.cyclomatic,
4035 @r#"
4036 {
4037 "sum": 4,
4038 "value": 1,
4039 "average": 4.0,
4040 "min": 1,
4041 "max": 3,
4042 "modified": {
4043 "sum": 4,
4044 "value": 1,
4045 "average": 4.0,
4046 "min": 1,
4047 "max": 3
4048 }
4049 }
4050 "#
4051 );
4052 },
4053 );
4054 }
4055
4056 #[test]
4057 fn typescript_nullish_coalescing_assignment_231() {
4058 // TypeScript must count `??=` the same as JS.
4059 check_metrics::<TypescriptParser>(
4060 "function pick(o: { x?: number; y?: number }) { // +1 (entry)
4061 o.x ??= 1; // +1 (??=)
4062 o.y ??= 2; // +1 (??=)
4063 return o;
4064 }",
4065 "foo.ts",
4066 |metric| {
4067 // unit(1) + fn(entry 1 + 2*??= = 3) = sum 4, max 3.
4068 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4069 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4070 insta::assert_json_snapshot!(
4071 metric.cyclomatic,
4072 @r#"
4073 {
4074 "sum": 4,
4075 "value": 1,
4076 "average": 4.0,
4077 "min": 1,
4078 "max": 3,
4079 "modified": {
4080 "sum": 4,
4081 "value": 1,
4082 "average": 4.0,
4083 "min": 1,
4084 "max": 3
4085 }
4086 }
4087 "#
4088 );
4089 },
4090 );
4091 }
4092
4093 #[test]
4094 fn tsx_nullish_coalescing_assignment_231() {
4095 // TSX must count `??=` the same as JS/TS.
4096 check_metrics::<TsxParser>(
4097 "function pick(o: { x?: number; y?: number }) { // +1 (entry)
4098 o.x ??= 1; // +1 (??=)
4099 o.y ??= 2; // +1 (??=)
4100 return o;
4101 }",
4102 "foo.tsx",
4103 |metric| {
4104 // unit(1) + fn(entry 1 + 2*??= = 3) = sum 4, max 3.
4105 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4106 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4107 insta::assert_json_snapshot!(
4108 metric.cyclomatic,
4109 @r#"
4110 {
4111 "sum": 4,
4112 "value": 1,
4113 "average": 4.0,
4114 "min": 1,
4115 "max": 3,
4116 "modified": {
4117 "sum": 4,
4118 "value": 1,
4119 "average": 4.0,
4120 "min": 1,
4121 "max": 3
4122 }
4123 }
4124 "#
4125 );
4126 },
4127 );
4128 }
4129
4130 #[test]
4131 fn mozjs_nullish_coalescing_assignment_231() {
4132 // Mozjs must count `??=` the same as JS.
4133 check_metrics::<MozjsParser>(
4134 "function pick(o) { // +1 (entry)
4135 o.x ??= 1; // +1 (??=)
4136 o.y ??= 2; // +1 (??=)
4137 return o;
4138 }",
4139 "foo.js",
4140 |metric| {
4141 // unit(1) + fn(entry 1 + 2*??= = 3) = sum 4, max 3.
4142 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4143 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4144 insta::assert_json_snapshot!(
4145 metric.cyclomatic,
4146 @r#"
4147 {
4148 "sum": 4,
4149 "value": 1,
4150 "average": 4.0,
4151 "min": 1,
4152 "max": 3,
4153 "modified": {
4154 "sum": 4,
4155 "value": 1,
4156 "average": 4.0,
4157 "min": 1,
4158 "max": 3
4159 }
4160 }
4161 "#
4162 );
4163 },
4164 );
4165 }
4166
4167 #[test]
4168 fn javascript_short_circuit_assignments_248() {
4169 // `&&=`, `||=`, `??=` are each one short-circuit decision edge —
4170 // semantically `x = x op y`. #231 added only `??=`; #248 adds the
4171 // sibling `&&=` and `||=`.
4172 check_metrics::<JavascriptParser>(
4173 "function f(x, y, z) { // +1 (entry)
4174 x ??= 1; // +1 (??=)
4175 y &&= 2; // +1 (&&=)
4176 z ||= 3; // +1 (||=)
4177 return x;
4178 }",
4179 "foo.js",
4180 |metric| {
4181 // unit(1) + fn(entry 1 + 3 assignments = 4) = sum 5, max 4.
4182 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
4183 assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
4184 insta::assert_json_snapshot!(
4185 metric.cyclomatic,
4186 @r#"
4187 {
4188 "sum": 5,
4189 "value": 1,
4190 "average": 5.0,
4191 "min": 1,
4192 "max": 4,
4193 "modified": {
4194 "sum": 5,
4195 "value": 1,
4196 "average": 5.0,
4197 "min": 1,
4198 "max": 4
4199 }
4200 }
4201 "#
4202 );
4203 },
4204 );
4205 }
4206
4207 #[test]
4208 fn typescript_short_circuit_assignments_248() {
4209 // TypeScript parallel of #248: `&&=` / `||=` / `??=` each +1.
4210 check_metrics::<TypescriptParser>(
4211 "function f(x: number | null, y: number | null, z: number | null): number { // +1 (entry)
4212 x ??= 1; // +1 (??=)
4213 y &&= 2; // +1 (&&=)
4214 z ||= 3; // +1 (||=)
4215 return x ?? 0; // +1 (??)
4216 }",
4217 "foo.ts",
4218 |metric| {
4219 // unit(1) + fn(entry 1 + 3 op= + 1 `??` = 5) = sum 6, max 5.
4220 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 6);
4221 assert_eq!(metric.cyclomatic.cyclomatic_max(), 5);
4222 insta::assert_json_snapshot!(
4223 metric.cyclomatic,
4224 @r#"
4225 {
4226 "sum": 6,
4227 "value": 1,
4228 "average": 6.0,
4229 "min": 1,
4230 "max": 5,
4231 "modified": {
4232 "sum": 6,
4233 "value": 1,
4234 "average": 6.0,
4235 "min": 1,
4236 "max": 5
4237 }
4238 }
4239 "#
4240 );
4241 },
4242 );
4243 }
4244
4245 #[test]
4246 fn tsx_short_circuit_assignments_248() {
4247 // TSX parallel of #248: `&&=` / `||=` / `??=` each +1.
4248 check_metrics::<TsxParser>(
4249 "function f(x: number | null, y: number | null, z: number | null): number { // +1 (entry)
4250 x ??= 1; // +1 (??=)
4251 y &&= 2; // +1 (&&=)
4252 z ||= 3; // +1 (||=)
4253 return x ?? 0; // +1 (??)
4254 }",
4255 "foo.tsx",
4256 |metric| {
4257 // unit(1) + fn(entry 1 + 3 op= + 1 `??` = 5) = sum 6, max 5.
4258 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 6);
4259 assert_eq!(metric.cyclomatic.cyclomatic_max(), 5);
4260 insta::assert_json_snapshot!(
4261 metric.cyclomatic,
4262 @r#"
4263 {
4264 "sum": 6,
4265 "value": 1,
4266 "average": 6.0,
4267 "min": 1,
4268 "max": 5,
4269 "modified": {
4270 "sum": 6,
4271 "value": 1,
4272 "average": 6.0,
4273 "min": 1,
4274 "max": 5
4275 }
4276 }
4277 "#
4278 );
4279 },
4280 );
4281 }
4282
4283 #[test]
4284 fn mozjs_short_circuit_assignments_248() {
4285 // Mozjs parallel of #248: `&&=` / `||=` / `??=` each +1.
4286 check_metrics::<MozjsParser>(
4287 "function f(x, y, z) { // +1 (entry)
4288 x ??= 1; // +1 (??=)
4289 y &&= 2; // +1 (&&=)
4290 z ||= 3; // +1 (||=)
4291 return x;
4292 }",
4293 "foo.js",
4294 |metric| {
4295 // unit(1) + fn(entry 1 + 3 assignments = 4) = sum 5, max 4.
4296 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
4297 assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
4298 insta::assert_json_snapshot!(
4299 metric.cyclomatic,
4300 @r#"
4301 {
4302 "sum": 5,
4303 "value": 1,
4304 "average": 5.0,
4305 "min": 1,
4306 "max": 4,
4307 "modified": {
4308 "sum": 5,
4309 "value": 1,
4310 "average": 5.0,
4311 "min": 1,
4312 "max": 4
4313 }
4314 }
4315 "#
4316 );
4317 },
4318 );
4319 }
4320
4321 // Issue #281: optional chaining (`?.`) is short-circuit (it skips
4322 // the rest of the chain when the LHS is nullish), so each `?.`
4323 // adds one cyclomatic decision point. Before the fix, JS-family
4324 // cyclomatic ignored `?.` entirely. The four tests below mirror
4325 // the existing `nullish_coalescing_chain_226` pattern but for
4326 // `?.`: two `?.` in a chain add +2 on top of the function entry.
4327 #[test]
4328 fn javascript_optional_chain_counted_in_cyclomatic_281() {
4329 check_metrics::<JavascriptParser>(
4330 "function pick(a) { // +1 (entry)
4331 return a?.b?.c; // +2 (two `?.`)
4332 }",
4333 "foo.js",
4334 |metric| {
4335 // unit(1) + fn(entry 1 + 2*?. = 3) = sum 4, max 3.
4336 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4337 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4338 },
4339 );
4340 }
4341
4342 #[test]
4343 fn mozjs_optional_chain_counted_in_cyclomatic_281() {
4344 check_metrics::<MozjsParser>(
4345 "function pick(a) { // +1 (entry)
4346 return a?.b?.c; // +2 (two `?.`)
4347 }",
4348 "foo.js",
4349 |metric| {
4350 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4351 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4352 },
4353 );
4354 }
4355
4356 #[test]
4357 fn typescript_optional_chain_counted_in_cyclomatic_281() {
4358 // TS exposes `?.` as both an `optional_chain` wrapper (over
4359 // member expressions) and a bare token (over call
4360 // expressions). We dispatch on `QMARKDOT` so every textual
4361 // `?.` adds exactly one decision point regardless of context.
4362 check_metrics::<TypescriptParser>(
4363 "function pick(a: any) { // +1 (entry)
4364 return a?.b?.c; // +2 (two `?.`)
4365 }",
4366 "foo.ts",
4367 |metric| {
4368 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4369 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4370 },
4371 );
4372 }
4373
4374 #[test]
4375 fn tsx_optional_chain_counted_in_cyclomatic_281() {
4376 check_metrics::<TsxParser>(
4377 "function pick(a: any) { // +1 (entry)
4378 return a?.b?.c; // +2 (two `?.`)
4379 }",
4380 "foo.tsx",
4381 |metric| {
4382 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4383 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4384 },
4385 );
4386 }
4387
4388 // Mix of member-expression `?.` and call-expression `?.()`:
4389 // ensures the TS/TSX dispatch on `QMARKDOT` (not the wrapper)
4390 // counts both forms exactly once. Both forms emit the bare `?.`
4391 // token; the wrapper only appears around member expressions.
4392 #[test]
4393 fn typescript_optional_chain_call_form_counted_281() {
4394 check_metrics::<TypescriptParser>(
4395 "function pick(a: any) { // +1 (entry)
4396 return a?.b?.(); // +2 (member `?.` + call `?.`)
4397 }",
4398 "foo.ts",
4399 |metric| {
4400 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4401 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4402 },
4403 );
4404 }
4405
4406 #[test]
4407 fn tsx_optional_chain_call_form_counted_281() {
4408 check_metrics::<TsxParser>(
4409 "function pick(a: any) { // +1 (entry)
4410 return a?.b?.(); // +2 (member `?.` + call `?.`)
4411 }",
4412 "foo.tsx",
4413 |metric| {
4414 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4415 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4416 },
4417 );
4418 }
4419
4420 #[test]
4421 fn csharp_nullish_coalescing_assignment_231() {
4422 // C#'s `??=` is short-circuit (RHS evaluates only when LHS is null)
4423 // and must add +1 cyclomatic per occurrence (#231).
4424 check_metrics::<CsharpParser>(
4425 "public class A {
4426 public int? x;
4427 public int? y;
4428 public void Pick() { // +1 (entry)
4429 x ??= 1; // +1 (??=)
4430 y ??= 2; // +1 (??=)
4431 }
4432 }",
4433 "foo.cs",
4434 |metric| {
4435 // unit(1) + class(1) + Pick(entry 1 + 2*??= = 3) = sum 5,
4436 // max 3 (Pick).
4437 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
4438 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4439 insta::assert_json_snapshot!(
4440 metric.cyclomatic,
4441 @r#"
4442 {
4443 "sum": 5,
4444 "value": 1,
4445 "average": 5.0,
4446 "min": 1,
4447 "max": 3,
4448 "modified": {
4449 "sum": 5,
4450 "value": 1,
4451 "average": 5.0,
4452 "min": 1,
4453 "max": 3
4454 }
4455 }
4456 "#
4457 );
4458 },
4459 );
4460 }
4461
4462 #[test]
4463 fn mozjs_while_loop() {
4464 check_metrics::<MozjsParser>(
4465 "function f(n) { // +2 (+1 unit)
4466 var i = 0;
4467 while (i < n) { // +1
4468 i++;
4469 }
4470 return i;
4471 }",
4472 "foo.js",
4473 |metric| {
4474 // unit(1) + fn(base 1 + while 1) = sum 3, max 2.
4475 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4476 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4477 insta::assert_json_snapshot!(metric.cyclomatic);
4478 },
4479 );
4480 }
4481
4482 #[test]
4483 fn bash_while_loop() {
4484 check_metrics::<BashParser>(
4485 "#!/bin/bash
4486f() {
4487 local n=$1
4488 while [ $n -gt 0 ]; do
4489 echo $n
4490 n=$((n - 1))
4491 done
4492}",
4493 "foo.sh",
4494 |metric| {
4495 // unit(1) + fn(base 1 + while 1) = sum 3, max 2.
4496 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4497 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4498 insta::assert_json_snapshot!(metric.cyclomatic);
4499 },
4500 );
4501 }
4502
4503 #[test]
4504 fn bash_case_statement() {
4505 check_metrics::<BashParser>(
4506 "#!/bin/bash
4507f() {
4508 case $1 in
4509 start) echo starting ;;
4510 stop) echo stopping ;;
4511 *) echo unknown ;;
4512 esac
4513}",
4514 "foo.sh",
4515 |metric| {
4516 // standard: unit(1) + fn(base 1 + 2 explicit case_items;
4517 // `*)` skipped per #211) = sum 4, max 3.
4518 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4519 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4520 insta::assert_json_snapshot!(metric.cyclomatic);
4521 },
4522 );
4523 }
4524
4525 /// Regression #211: a bare `*)` arm is Bash's analogue of the
4526 /// C-family `default:` and must NOT contribute to standard CCN.
4527 /// Without the fix, this 2-arm case reports `cyclomatic_max == 3`
4528 /// (1 base + 2 arms); with the fix it reports `2` (1 base + 1
4529 /// explicit arm), matching every other switch-bearing language
4530 /// in `tests/cyclomatic_cross_language_parity.rs`.
4531 #[test]
4532 fn bash_case_bare_wildcard_excluded() {
4533 check_metrics::<BashParser>(
4534 "#!/bin/bash
4535f() {
4536 case \"$1\" in
4537 one) echo 1 ;;
4538 *) echo 0 ;;
4539 esac
4540}",
4541 "foo.sh",
4542 |metric| {
4543 // standard: unit(1) + fn(base 1 + 1 explicit; `*)` skipped) = 3, max 2.
4544 // modified: unit(1) + fn(base 1 + case_stmt 1) = 3, max 2.
4545 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4546 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4547 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
4548 assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 2);
4549 insta::assert_json_snapshot!(metric.cyclomatic);
4550 },
4551 );
4552 }
4553
4554 /// A multi-value pattern containing `*` (`a|*)`) is NOT a bare
4555 /// wildcard — both alternations make it a non-default case. The
4556 /// arm still contributes one standard decision.
4557 #[test]
4558 fn bash_case_multi_value_with_star_counts() {
4559 check_metrics::<BashParser>(
4560 "#!/bin/bash
4561f() {
4562 case \"$1\" in
4563 a|*) echo any ;;
4564 esac
4565}",
4566 "foo.sh",
4567 |metric| {
4568 // standard: unit(1) + fn(base 1 + 1 arm) = 3, max 2.
4569 // The `a|*` pattern has TWO `value` fields, so the
4570 // bare-wildcard filter (`value_count == 1`) skips it.
4571 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4572 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4573 },
4574 );
4575 }
4576
4577 #[test]
4578 fn bash_simple_function() {
4579 check_metrics::<BashParser>(
4580 "#!/bin/bash
4581f() {
4582 echo hello
4583}",
4584 "foo.sh",
4585 |metric| {
4586 // unit(1) + fn(base 1) = sum 2, max 1.
4587 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 2);
4588 assert_eq!(metric.cyclomatic.cyclomatic_max(), 1);
4589 insta::assert_json_snapshot!(metric.cyclomatic);
4590 },
4591 );
4592 }
4593
4594 #[test]
4595 fn kotlin_for_loop() {
4596 check_metrics::<KotlinParser>(
4597 "fun sum(n: Int): Int { // +2 (+1 unit)
4598 var s = 0
4599 for (i in 1..n) { // +1
4600 s += i
4601 }
4602 return s
4603 }",
4604 "foo.kt",
4605 |metric| {
4606 // unit(1) + fn(base 1 + for 1) = sum 3, max 2.
4607 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4608 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4609 insta::assert_json_snapshot!(metric.cyclomatic);
4610 },
4611 );
4612 }
4613
4614 #[test]
4615 fn kotlin_while_loop() {
4616 check_metrics::<KotlinParser>(
4617 "fun countdown(n: Int): Int { // +2 (+1 unit)
4618 var i = n
4619 while (i > 0) { // +1
4620 i--
4621 }
4622 return i
4623 }",
4624 "foo.kt",
4625 |metric| {
4626 // unit(1) + fn(base 1 + while 1) = sum 3, max 2.
4627 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4628 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4629 insta::assert_json_snapshot!(metric.cyclomatic);
4630 },
4631 );
4632 }
4633
4634 #[test]
4635 fn kotlin_logical_operators() {
4636 check_metrics::<KotlinParser>(
4637 "fun check(a: Boolean, b: Boolean, c: Boolean): Boolean { // +2 (+1 unit)
4638 return a && b || c // +1 &&, +1 ||
4639 }",
4640 "foo.kt",
4641 |metric| {
4642 // unit(1) + fn(base 1 + && 1 + || 1) = sum 4, max 3.
4643 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4644 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4645 insta::assert_json_snapshot!(metric.cyclomatic);
4646 },
4647 );
4648 }
4649
4650 #[test]
4651 fn kotlin_elvis_operator_239() {
4652 // Regression for issue #239: Kotlin's Elvis operator `?:` is a
4653 // short-circuit nullish operator analogous to JS `??` and each
4654 // occurrence is a distinct decision point, mirroring `&&` /
4655 // `||`. `a ?: b ?: c` contributes +2 to the function's
4656 // cyclomatic complexity (base 1 + two `?:` = 3).
4657 check_metrics::<KotlinParser>(
4658 "fun pick(a: String?, b: String?, c: String): String { // +2 (+1 unit)
4659 return a ?: b ?: c // +2 (two ?: short-circuits)
4660 }",
4661 "foo.kt",
4662 |metric| {
4663 // unit(1) + fn(base 1 + ?: 1 + ?: 1) = sum 4, max 3.
4664 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4665 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4666 insta::assert_json_snapshot!(
4667 metric.cyclomatic,
4668 @r#"
4669 {
4670 "sum": 4,
4671 "value": 1,
4672 "average": 4.0,
4673 "min": 1,
4674 "max": 3,
4675 "modified": {
4676 "sum": 4,
4677 "value": 1,
4678 "average": 4.0,
4679 "min": 1,
4680 "max": 3
4681 }
4682 }
4683 "#
4684 );
4685 },
4686 );
4687 }
4688
4689 #[test]
4690 fn kotlin_safe_navigation_436() {
4691 // Issue #436: Kotlin's safe-navigation `?.` is a short-circuit
4692 // decision point, mirroring the JS/TS/C# treatment of `?.`
4693 // (#281). Each `?.` adds +1; the chain `a?.b?.c` adds +2.
4694 check_metrics::<KotlinParser>(
4695 "fun read(a: A?): String? { // +2 (+1 unit)
4696 return a?.b?.c // +2 (two ?. short-circuits)
4697 }",
4698 "foo.kt",
4699 |metric| {
4700 // unit(1) + fn(base 1 + ?. 1 + ?. 1) = sum 4, max 3.
4701 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4702 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4703 // modified mirrors standard: each `?.` is both-metric.
4704 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
4705 assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 3);
4706 },
4707 );
4708 }
4709
4710 #[test]
4711 fn typescript_for_loop() {
4712 check_metrics::<TypescriptParser>(
4713 "function sum(n: number): number { // +2 (+1 unit)
4714 let s = 0;
4715 for (let i = 0; i < n; i++) { // +1
4716 s += i;
4717 }
4718 return s;
4719 }",
4720 "foo.ts",
4721 |metric| {
4722 // unit(1) + fn(base 1 + for 1) = sum 3, max 2.
4723 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4724 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4725 insta::assert_json_snapshot!(metric.cyclomatic);
4726 },
4727 );
4728 }
4729
4730 #[test]
4731 fn typescript_while_loop() {
4732 check_metrics::<TypescriptParser>(
4733 "function countdown(n: number): number { // +2 (+1 unit)
4734 let i = n;
4735 while (i > 0) { // +1
4736 i--;
4737 }
4738 return i;
4739 }",
4740 "foo.ts",
4741 |metric| {
4742 // unit(1) + fn(base 1 + while 1) = sum 3, max 2.
4743 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4744 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4745 insta::assert_json_snapshot!(metric.cyclomatic);
4746 },
4747 );
4748 }
4749
4750 #[test]
4751 fn typescript_logical_operators() {
4752 check_metrics::<TypescriptParser>(
4753 "function check(a: boolean, b: boolean, c: boolean): boolean { // +2 (+1 unit)
4754 return a && b || c; // +1 &&, +1 ||
4755 }",
4756 "foo.ts",
4757 |metric| {
4758 // unit(1) + fn(base 1 + && 1 + || 1) = sum 4, max 3.
4759 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4760 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4761 insta::assert_json_snapshot!(metric.cyclomatic);
4762 },
4763 );
4764 }
4765
4766 #[test]
4767 fn typescript_try_catch() {
4768 check_metrics::<TypescriptParser>(
4769 "function safe(x: number): number { // +2 (+1 unit)
4770 try {
4771 return 1 / x;
4772 } catch (e) { // +1
4773 return 0;
4774 }
4775 }",
4776 "foo.ts",
4777 |metric| {
4778 // unit(1) + fn(base 1 + catch 1) = sum 3, max 2.
4779 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4780 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4781 insta::assert_json_snapshot!(metric.cyclomatic);
4782 },
4783 );
4784 }
4785
4786 #[test]
4787 fn tsx_for_loop() {
4788 check_metrics::<TsxParser>(
4789 "function sum(n: number): number { // +2 (+1 unit)
4790 let s = 0;
4791 for (let i = 0; i < n; i++) { // +1
4792 s += i;
4793 }
4794 return s;
4795 }",
4796 "foo.tsx",
4797 |metric| {
4798 // unit(1) + fn(base 1 + for 1) = sum 3, max 2.
4799 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4800 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4801 insta::assert_json_snapshot!(metric.cyclomatic);
4802 },
4803 );
4804 }
4805
4806 #[test]
4807 fn tsx_while_loop() {
4808 check_metrics::<TsxParser>(
4809 "function countdown(n: number): number { // +2 (+1 unit)
4810 let i = n;
4811 while (i > 0) { // +1
4812 i--;
4813 }
4814 return i;
4815 }",
4816 "foo.tsx",
4817 |metric| {
4818 // unit(1) + fn(base 1 + while 1) = sum 3, max 2.
4819 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4820 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4821 insta::assert_json_snapshot!(metric.cyclomatic);
4822 },
4823 );
4824 }
4825
4826 #[test]
4827 fn tsx_logical_operators() {
4828 check_metrics::<TsxParser>(
4829 "function check(a: boolean, b: boolean, c: boolean): boolean { // +2 (+1 unit)
4830 return a && b || c; // +1 &&, +1 ||
4831 }",
4832 "foo.tsx",
4833 |metric| {
4834 // unit(1) + fn(base 1 + && 1 + || 1) = sum 4, max 3.
4835 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4836 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4837 insta::assert_json_snapshot!(metric.cyclomatic);
4838 },
4839 );
4840 }
4841
4842 #[test]
4843 fn tsx_try_catch() {
4844 check_metrics::<TsxParser>(
4845 "function safe(x: number): number { // +2 (+1 unit)
4846 try {
4847 return 1 / x;
4848 } catch (e) { // +1
4849 return 0;
4850 }
4851 }",
4852 "foo.tsx",
4853 |metric| {
4854 // unit(1) + fn(base 1 + catch 1) = sum 3, max 2.
4855 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
4856 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
4857 insta::assert_json_snapshot!(metric.cyclomatic);
4858 },
4859 );
4860 }
4861
4862 #[test]
4863 fn tsx_switch() {
4864 check_metrics::<TsxParser>(
4865 "function describe(x: number): string { // +2 (+1 unit)
4866 switch (x) {
4867 case 1: // +1
4868 return 'one';
4869 case 2: // +1
4870 return 'two';
4871 default:
4872 return 'other';
4873 }
4874 }",
4875 "foo.tsx",
4876 |metric| {
4877 // unit(1) + fn(base 1 + 2 cases) = sum 4, max 3.
4878 // default does NOT add a branch.
4879 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4880 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4881 insta::assert_json_snapshot!(metric.cyclomatic);
4882 },
4883 );
4884 }
4885
4886 /// Modified CCN: TSX switch with 2 cases collapses to 1.
4887 #[test]
4888 fn tsx_switch_modified() {
4889 check_metrics::<TsxParser>(
4890 "function f(x: number): string {
4891 switch (x) {
4892 case 1: return 'one';
4893 case 2: return 'two';
4894 default: return 'other';
4895 }
4896 }",
4897 "foo.tsx",
4898 |metric| {
4899 // standard: unit(1) + fn(1) + 2 cases = sum 4, max 3.
4900 // modified: unit(1) + fn(1) + switch(1) = sum 3, max 2.
4901 // default does NOT add a branch.
4902 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4903 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
4904 insta::assert_json_snapshot!(metric.cyclomatic);
4905 },
4906 );
4907 }
4908
4909 #[test]
4910 fn php_1_level_nesting() {
4911 // Mirrors java_simple_class' if-inside-method shape:
4912 // unit (+1) + function (+1) + if (+1) + && (+1) = sum 4.
4913 check_metrics::<PhpParser>(
4914 "<?php
4915 function f(int $a, int $b): bool {
4916 if ($a > 0 && $b > 0) {
4917 return true;
4918 }
4919 return false;
4920 }",
4921 "foo.php",
4922 |metric| {
4923 insta::assert_json_snapshot!(
4924 metric.cyclomatic,
4925 @r#"
4926 {
4927 "sum": 4,
4928 "value": 1,
4929 "average": 4.0,
4930 "min": 1,
4931 "max": 3,
4932 "modified": {
4933 "sum": 4,
4934 "value": 1,
4935 "average": 4.0,
4936 "min": 1,
4937 "max": 3
4938 }
4939 }
4940 "#
4941 );
4942 },
4943 );
4944 }
4945
4946 // `case`/`cond`/`with` arms surface as `stab_clause` nodes and
4947 // contribute to standard CCN, mirroring the C-family `case:` arm
4948 // treatment. The container Call (`case`) contributes once to
4949 // modified CCN, collapsing arms back to a single decision point.
4950 // Three func spaces (Unit + defmodule Class + def Function) each
4951 // seed one entry: standard = 3 entries + 3 stabs = 6; modified =
4952 // 3 entries + 1 case Call = 4.
4953 #[test]
4954 fn elixir_case_arms() {
4955 check_metrics::<ElixirParser>(
4956 "defmodule Foo do\n def classify(x) do\n case x do\n 1 -> :one\n 2 -> :two\n _ -> :other\n end\n end\nend\n",
4957 "foo.ex",
4958 |metric| {
4959 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 6);
4960 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
4961 },
4962 );
4963 }
4964
4965 // Each short-circuit boolean (`&&`, `||`, `and`, `or`) is one
4966 // decision point — Elixir does not expose `if`/`unless` as a
4967 // distinct kind_id, so this is the only operator-driven path the
4968 // metric can see.
4969 #[test]
4970 fn elixir_logical_operators() {
4971 check_metrics::<ElixirParser>(
4972 "defmodule Foo do\n def f(x, y) do\n x and y or (x && y) || x\n end\nend\n",
4973 "foo.ex",
4974 |metric| {
4975 // 4 short-circuit ops + 3 entries (Unit, defmodule, def) = 7.
4976 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 7);
4977 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 7);
4978 },
4979 );
4980 }
4981
4982 // `try`/`rescue`/`catch` is a multi-arm container Call: the `try`
4983 // Call contributes once to modified CCN, while each rescue/catch
4984 // arm's matched pattern (a `stab_clause`) contributes once to
4985 // standard CCN. This mirrors C-family `try`/`catch` semantics.
4986 #[test]
4987 fn elixir_try_rescue() {
4988 check_metrics::<ElixirParser>(
4989 "defmodule Foo do\n def safe do\n try do\n do_it()\n rescue\n ArgumentError -> :bad\n end\n end\nend\n",
4990 "foo.ex",
4991 |metric| {
4992 // standard: 3 entries + 1 rescue stab = 4
4993 // modified: 3 entries + 1 try Call = 4
4994 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
4995 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
4996 },
4997 );
4998 }
4999
5000 // `if x do ... else ... end` surfaces as a `Call(target=if)`; the
5001 // metric inspects the source text of the call's target field to
5002 // identify it. Single-branch keyword Calls (`if`/`unless`/`for`/
5003 // `while`) contribute to both standard and modified CCN.
5004 #[test]
5005 fn elixir_if_else_counts() {
5006 check_metrics::<ElixirParser>(
5007 "defmodule Foo do\n def f(x) do\n if x > 0 do\n :pos\n else\n :neg\n end\n end\nend\n",
5008 "foo.ex",
5009 |metric| {
5010 // 1 if Call + 3 entries (Unit, defmodule Class, def Function) = 4.
5011 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
5012 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5013 },
5014 );
5015 }
5016
5017 // `if x do ... end` without an `else` clause still surfaces as
5018 // `Call(target=if)` and is counted identically to the if/else
5019 // form — the `else` keyword is a do-block keyword argument, not
5020 // an extra `stab_clause`, so its presence does not change the
5021 // cyclomatic count.
5022 #[test]
5023 fn elixir_if_without_else_counts() {
5024 check_metrics::<ElixirParser>(
5025 "defmodule Foo do\n def f(x) do\n if x > 0 do\n :pos\n end\n end\nend\n",
5026 "foo.ex",
5027 |metric| {
5028 // 1 if Call + 3 entries = 4.
5029 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
5030 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5031 },
5032 );
5033 }
5034
5035 // `unless x do ... end` is the negated `if`; it surfaces as
5036 // `Call(target=unless)` and is treated identically to `if`.
5037 #[test]
5038 fn elixir_unless_counts() {
5039 check_metrics::<ElixirParser>(
5040 "defmodule Foo do\n def f(x) do\n unless x > 0 do\n :nonpos\n end\n end\nend\n",
5041 "foo.ex",
5042 |metric| {
5043 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
5044 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5045 },
5046 );
5047 }
5048
5049 // `for x <- list, do: ...` is Elixir's comprehension generator —
5050 // a `Call(target=for)`. Counts once for both standard and
5051 // modified, mirroring `if`/`unless`.
5052 #[test]
5053 fn elixir_for_comprehension_counts() {
5054 check_metrics::<ElixirParser>(
5055 "defmodule Foo do\n def f(xs) do\n for x <- xs do\n x * 2\n end\n end\nend\n",
5056 "foo.ex",
5057 |metric| {
5058 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
5059 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5060 },
5061 );
5062 }
5063
5064 // `fn ... end` is its own function space (`get_space_kind` →
5065 // `Function`), so its cyclomatic gets its own `+1` entry path
5066 // alongside the Unit / defmodule Class / def Function entries.
5067 // The FIRST `stab_clause` is the closure's head/definition and does
5068 // NOT count (issue #776); only the 2nd+ clauses are pattern-dispatch
5069 // branches. The anon-fn itself is not a `Call`, so it adds no
5070 // modified-CCN container decision. Standard = 4 entries (Unit,
5071 // defmodule, def, anon-fn) + 1 branch (2nd clause) = 5; modified =
5072 // 4 entries = 4.
5073 #[test]
5074 fn elixir_anonymous_fn_arms_count() {
5075 check_metrics::<ElixirParser>(
5076 "defmodule Foo do\n def f do\n multi = fn 0 -> :zero; _ -> :other end\n multi.(0)\n end\nend\n",
5077 "foo.ex",
5078 |metric| {
5079 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
5080 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5081 },
5082 );
5083 }
5084
5085 // Regression for issue #776: a single-clause anonymous function
5086 // (`fn x -> x end`) has zero decision points — its lone
5087 // `stab_clause` is the closure head, not a branch. The closure's
5088 // own function space must therefore report cyclomatic 1 (base
5089 // entry only), matching cognitive's treatment (`cognitive.rs`
5090 // `elixir_enum_reduce_is_zero`). Before the fix the head clause
5091 // added a spurious +1, reporting 2. Standard = 4 entries (Unit,
5092 // defmodule, def, anon-fn) + 0 branches = 4; modified = 4.
5093 #[test]
5094 fn elixir_single_clause_anonymous_fn_is_not_a_branch() {
5095 check_metrics::<ElixirParser>(
5096 "defmodule Foo do\n def f do\n id = fn x -> x end\n id.(1)\n end\nend\n",
5097 "foo.ex",
5098 |metric| {
5099 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
5100 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5101 },
5102 );
5103 }
5104
5105 // `cond do ... end` is the standard Elixir multi-way conditional.
5106 // Each clause is a `stab_clause` (standard CCN), and the `cond`
5107 // Call is a multi-arm container (modified CCN, once).
5108 #[test]
5109 fn elixir_cond_arms() {
5110 check_metrics::<ElixirParser>(
5111 "defmodule Foo do\n def f(x) do\n cond do\n x < 0 -> :neg\n x == 0 -> :zero\n true -> :pos\n end\n end\nend\n",
5112 "foo.ex",
5113 |metric| {
5114 // standard: 3 entries + 3 stabs = 6
5115 // modified: 3 entries + 1 cond Call = 4
5116 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 6);
5117 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5118 },
5119 );
5120 }
5121
5122 // `with` chains use `<-` arrows, which parse as `binary_operator`
5123 // nodes — NOT `stab_clause`s — so the `with`-head clauses do not
5124 // contribute to standard CCN per-arm. The fallthrough `else`
5125 // branch, when present, contains `stab_clause`s that count for
5126 // standard. The `with` Call itself is a multi-arm container Call
5127 // that contributes once to modified CCN.
5128 #[test]
5129 fn elixir_with_else_only_counts_else_arms() {
5130 check_metrics::<ElixirParser>(
5131 "defmodule Foo do\n def f(x) do\n with {:ok, v} <- fetch(x),\n {:ok, w} <- fetch(v) do\n {:ok, w}\n else\n :error -> :nope\n other -> {:bad, other}\n end\n end\nend\n",
5132 "foo.ex",
5133 |metric| {
5134 // standard: 3 entries + 2 else-block stabs = 5
5135 // modified: 3 entries + 1 with Call = 4
5136 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
5137 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5138 },
5139 );
5140 }
5141
5142 #[test]
5143 fn php_match_expression() {
5144 // Each `match_conditional_expression` arm (+1) but the default arm
5145 // does NOT add a branch (mirrors switch/case Java semantics).
5146 check_metrics::<PhpParser>(
5147 "<?php
5148 function color(string $c): int {
5149 return match ($c) {
5150 'red' => 1,
5151 'green' => 2,
5152 'blue' => 3,
5153 default => 0,
5154 };
5155 }",
5156 "foo.php",
5157 |metric| {
5158 // unit (+1) + function (+1) + 3 match arms (+3) = sum 5.
5159 // Default arm contributes 0.
5160 insta::assert_json_snapshot!(
5161 metric.cyclomatic,
5162 @r#"
5163 {
5164 "sum": 5,
5165 "value": 1,
5166 "average": 5.0,
5167 "min": 1,
5168 "max": 4,
5169 "modified": {
5170 "sum": 3,
5171 "value": 1,
5172 "average": 3.0,
5173 "min": 1,
5174 "max": 2
5175 }
5176 }
5177 "#
5178 );
5179 },
5180 );
5181 }
5182
5183 /// Modified CCN: PHP switch with 3 cases collapses to 1.
5184 #[test]
5185 fn php_switch_modified() {
5186 check_metrics::<PhpParser>(
5187 "<?php
5188 function describe(int $n): string {
5189 switch ($n) {
5190 case 1:
5191 return 'one';
5192 case 2:
5193 return 'two';
5194 case 3:
5195 return 'three';
5196 default:
5197 return 'other';
5198 }
5199 }",
5200 "foo.php",
5201 |metric| {
5202 // standard: unit(1) + fn(1) + 3 cases = sum 5, max 4.
5203 // modified: unit(1) + fn(1) + switch(1) = sum 3, max 2.
5204 // default does NOT add a branch.
5205 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
5206 assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
5207 insta::assert_json_snapshot!(metric.cyclomatic);
5208 },
5209 );
5210 }
5211
5212 #[test]
5213 fn php_null_coalescing() {
5214 // `??` and `??=` are each one short-circuit decision (#231).
5215 // Tree-sitter emits `??=` as the single token `QMARKQMARKEQ`, so it
5216 // is matched independently from the binary `??`.
5217 check_metrics::<PhpParser>(
5218 "<?php
5219 function pick($x, $y) {
5220 $a = $x ?? $y;
5221 $a ??= 0;
5222 return $a;
5223 }",
5224 "foo.php",
5225 |metric| {
5226 // unit (+1) + function (+1) + ?? (+1) + ??= (+1) = sum 4.
5227 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
5228 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
5229 insta::assert_json_snapshot!(
5230 metric.cyclomatic,
5231 @r#"
5232 {
5233 "sum": 4,
5234 "value": 1,
5235 "average": 4.0,
5236 "min": 1,
5237 "max": 3,
5238 "modified": {
5239 "sum": 4,
5240 "value": 1,
5241 "average": 4.0,
5242 "min": 1,
5243 "max": 3
5244 }
5245 }
5246 "#
5247 );
5248 },
5249 );
5250 }
5251
5252 #[test]
5253 fn php_nullsafe_operator_436() {
5254 // Issue #436: PHP's nullsafe operator `?->` is a short-circuit
5255 // decision point, mirroring the JS/TS/C# treatment of `?.`
5256 // (#281). The `QMARKDASHGT` token fires once per operator across
5257 // both property access (`$a?->b`) and method call (`$a?->c()`),
5258 // and once per link in a chain. Here: one access + one chained
5259 // call (`$a?->b?->c()`) = +2 for that statement.
5260 check_metrics::<PhpParser>(
5261 "<?php
5262 function read($a) {
5263 return $a?->b?->c();
5264 }",
5265 "foo.php",
5266 |metric| {
5267 // unit(1) + fn(base 1 + ?-> 1 + ?-> 1) = sum 4, max 3.
5268 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
5269 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
5270 // modified mirrors standard: each `?->` is both-metric.
5271 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
5272 assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 3);
5273 },
5274 );
5275 }
5276
5277 /// Modified CCN: nested switches contribute one decision each, not one
5278 /// total — the outer container does not absorb the inner one.
5279 #[test]
5280 fn cpp_nested_switch_modified() {
5281 check_metrics::<CppParser>(
5282 "void f() {
5283 switch (x) {
5284 case 1:
5285 switch (y) {
5286 case 10: break;
5287 case 20: break;
5288 }
5289 break;
5290 case 2: break;
5291 }
5292 }",
5293 "foo.c",
5294 |metric| {
5295 // standard: unit(1) + fn(1) + 4 cases = 6
5296 // modified: unit(1) + fn(1) + 2 switches = 4
5297 insta::assert_json_snapshot!(
5298 metric.cyclomatic,
5299 @r#"
5300 {
5301 "sum": 6,
5302 "value": 1,
5303 "average": 6.0,
5304 "min": 1,
5305 "max": 5,
5306 "modified": {
5307 "sum": 4,
5308 "value": 1,
5309 "average": 4.0,
5310 "min": 1,
5311 "max": 3
5312 }
5313 }
5314 "#
5315 );
5316 },
5317 );
5318 }
5319
5320 /// Modified CCN: nested Rust matches each contribute one container.
5321 /// Bare `_ =>` arms are skipped.
5322 #[test]
5323 fn rust_nested_match_modified() {
5324 check_metrics::<RustParser>(
5325 "fn f(x: u8) -> u8 {
5326 match x {
5327 1 => match x {
5328 10 => 1,
5329 20 => 2,
5330 _ => 0,
5331 },
5332 _ => 0,
5333 }
5334 }",
5335 "foo.rs",
5336 |metric| {
5337 // standard: unit(1) + fn(1) + 3 arms (1,10,20; both _ skipped) = 5
5338 // modified: unit(1) + fn(1) + 2 matches = 4
5339 insta::assert_json_snapshot!(
5340 metric.cyclomatic,
5341 @r#"
5342 {
5343 "sum": 5,
5344 "value": 1,
5345 "average": 5.0,
5346 "min": 1,
5347 "max": 4,
5348 "modified": {
5349 "sum": 4,
5350 "value": 1,
5351 "average": 4.0,
5352 "min": 1,
5353 "max": 3
5354 }
5355 }
5356 "#
5357 );
5358 },
5359 );
5360 }
5361
5362 /// Pin the empty-switch edge case: standard counts no arms (0) while
5363 /// modified still counts the container (+1) per Lizard's `-m`.
5364 #[test]
5365 fn cpp_empty_switch_modified() {
5366 check_metrics::<CppParser>("void f() { switch (x) {} }", "foo.c", |metric| {
5367 // standard: unit(1) + fn(1) + 0 cases = 2
5368 // modified: unit(1) + fn(1) + 1 switch = 3
5369 insta::assert_json_snapshot!(
5370 metric.cyclomatic,
5371 @r#"
5372 {
5373 "sum": 2,
5374 "value": 1,
5375 "average": 2.0,
5376 "min": 1,
5377 "max": 1,
5378 "modified": {
5379 "sum": 3,
5380 "value": 1,
5381 "average": 3.0,
5382 "min": 1,
5383 "max": 2
5384 }
5385 }
5386 "#
5387 );
5388 });
5389 }
5390
5391 /// Two nested `for` loops contribute +1 each on top of the function and
5392 /// unit decisions. No condition expressions, so `&&` / `||` do not fire.
5393 #[test]
5394 fn c_nested_loops() {
5395 check_metrics::<CParser>(
5396 "void f() {
5397 for (int i = 0; i < 10; ++i) { // +1
5398 for (int j = 0; j < 10; ++j) { // +1
5399 g(i, j);
5400 }
5401 }
5402 }",
5403 "foo.c",
5404 |metric| {
5405 // standard: unit(1) + fn(1) + 2 for = 4
5406 // modified: identical (no switch container, no extra arms)
5407 let s = &metric.cyclomatic;
5408 assert_eq!(s.cyclomatic_sum(), 4);
5409 assert_eq!(s.cyclomatic_max(), 3);
5410 assert_eq!(s.cyclomatic_modified_sum(), 4);
5411 insta::assert_json_snapshot!(
5412 metric.cyclomatic,
5413 @r#"
5414 {
5415 "sum": 4,
5416 "value": 1,
5417 "average": 4.0,
5418 "min": 1,
5419 "max": 3,
5420 "modified": {
5421 "sum": 4,
5422 "value": 1,
5423 "average": 4.0,
5424 "min": 1,
5425 "max": 3
5426 }
5427 }
5428 "#
5429 );
5430 },
5431 );
5432 }
5433
5434 /// C++ `do { … } while (…)` contributes exactly +1 to both
5435 /// standard and modified CCN. The +1 comes from the `while`
5436 /// keyword token inside the do-statement (`Cpp::While`), which the
5437 /// C-family macro already counts. Adding the `DoStatement`
5438 /// statement node would double-count — see the macro doc comment
5439 /// and issue #284. This test pins the correct keyword-driven
5440 /// count.
5441 #[test]
5442 fn cpp_do_statement_counts_in_cyclomatic() {
5443 check_metrics::<CppParser>(
5444 "void f() {
5445 int i = 0;
5446 do { // +1 (via inner `while` keyword)
5447 ++i;
5448 } while (i < 10);
5449 }",
5450 "foo.cpp",
5451 |metric| {
5452 // standard: unit(1) + fn(1) + do(1) = 3
5453 // modified: identical (no switch, no extra arms)
5454 let s = &metric.cyclomatic;
5455 assert_eq!(s.cyclomatic_sum(), 3);
5456 assert_eq!(s.cyclomatic_max(), 2);
5457 assert_eq!(s.cyclomatic_modified_sum(), 3);
5458 insta::assert_json_snapshot!(
5459 metric.cyclomatic,
5460 @r#"
5461 {
5462 "sum": 3,
5463 "value": 1,
5464 "average": 3.0,
5465 "min": 1,
5466 "max": 2,
5467 "modified": {
5468 "sum": 3,
5469 "value": 1,
5470 "average": 3.0,
5471 "min": 1,
5472 "max": 2
5473 }
5474 }
5475 "#
5476 );
5477 },
5478 );
5479 }
5480
5481 /// C++ range-based `for (auto x : xs)` contributes exactly +1 to
5482 /// both standard and modified CCN — the `for` keyword token
5483 /// (`Cpp::For`) fires inside the `ForRangeLoop` node just like
5484 /// inside a classic `ForStatement`. Pinning this prevents
5485 /// reintroducing the double-count from issue #284's incorrect fix
5486 /// proposal.
5487 #[test]
5488 fn cpp_for_range_loop_counts_in_cyclomatic() {
5489 check_metrics::<CppParser>(
5490 "void f(std::vector<int> xs) {
5491 for (auto x : xs) { // +1 (via `for` keyword)
5492 g(x);
5493 }
5494 }",
5495 "foo.cpp",
5496 |metric| {
5497 // standard: unit(1) + fn(1) + for-range(1) = 3
5498 let s = &metric.cyclomatic;
5499 assert_eq!(s.cyclomatic_sum(), 3);
5500 assert_eq!(s.cyclomatic_max(), 2);
5501 assert_eq!(s.cyclomatic_modified_sum(), 3);
5502 insta::assert_json_snapshot!(
5503 metric.cyclomatic,
5504 @r#"
5505 {
5506 "sum": 3,
5507 "value": 1,
5508 "average": 3.0,
5509 "min": 1,
5510 "max": 2,
5511 "modified": {
5512 "sum": 3,
5513 "value": 1,
5514 "average": 3.0,
5515 "min": 1,
5516 "max": 2
5517 }
5518 }
5519 "#
5520 );
5521 },
5522 );
5523 }
5524
5525 /// Decision kinds through the dedicated `LANG::C` grammar (#721):
5526 /// `if`, `for`, `while`, `case`, and the `&&` short-circuit each
5527 /// add +1; `switch` adds only to the modified count. C has no
5528 /// `catch`, so the hand-written `Cyclomatic for CCode` impl omits
5529 /// the exception arm the C++ macro carries.
5530 #[test]
5531 fn c_grammar_decision_kinds_count_in_cyclomatic() {
5532 check_metrics::<CParser>(
5533 "int f(int a, int b) {
5534 if (a && b) { // +1 if, +1 &&
5535 return 1;
5536 }
5537 for (int i = 0; i < a; ++i) { // +1 for
5538 b += i;
5539 }
5540 switch (b) { // +1 modified only
5541 case 0: return 0; // +1 case
5542 default: return b;
5543 }
5544 }",
5545 "foo.c",
5546 |metric| {
5547 let s = &metric.cyclomatic;
5548 // standard: unit(1) + fn(1) + if(1) + &&(1) + for(1) + case(1) = 6
5549 assert_eq!(s.cyclomatic_sum(), 6);
5550 // modified: `case` adds to standard only and `switch` to
5551 // modified only, so they balance — base(2) + if + && + for
5552 // + switch(1) = 6.
5553 assert_eq!(s.cyclomatic_modified_sum(), 6);
5554 },
5555 );
5556 }
5557
5558 /// `?:` ternary is matched by `Cpp::ConditionalExpression` in the
5559 /// C-family macro and contributes +1 standard *and* +1 modified.
5560 /// Two nested ternaries in one expression therefore add 2 to each.
5561 #[test]
5562 fn c_ternary_chain() {
5563 check_metrics::<CParser>(
5564 "int f(int a, int b, int c) {
5565 return a > 0 ? a : (b > 0 ? b : c); // +2 ternaries (?: each)
5566 }",
5567 "foo.c",
5568 |metric| {
5569 // standard: unit(1) + fn(1) + 2 ?: = 4
5570 let s = &metric.cyclomatic;
5571 assert_eq!(s.cyclomatic_sum(), 4);
5572 assert_eq!(s.cyclomatic_max(), 3);
5573 assert_eq!(s.cyclomatic_modified_sum(), 4);
5574 insta::assert_json_snapshot!(
5575 metric.cyclomatic,
5576 @r#"
5577 {
5578 "sum": 4,
5579 "value": 1,
5580 "average": 4.0,
5581 "min": 1,
5582 "max": 3,
5583 "modified": {
5584 "sum": 4,
5585 "value": 1,
5586 "average": 4.0,
5587 "min": 1,
5588 "max": 3
5589 }
5590 }
5591 "#
5592 );
5593 },
5594 );
5595 }
5596
5597 /// Short-circuit `&&` / `||` chains each contribute +1 — every binary
5598 /// operator token in the chain is a separate decision (Lizard parity).
5599 #[test]
5600 fn c_short_circuit_chain() {
5601 check_metrics::<CParser>(
5602 "int f(int a, int b, int c, int d) {
5603 if (a && b || c && d) { // 3 logical ops + 1 if = 4
5604 return 1;
5605 }
5606 return 0;
5607 }",
5608 "foo.c",
5609 |metric| {
5610 // standard: unit(1) + fn(1) + if(1) + && (2) + || (1) = 6
5611 let s = &metric.cyclomatic;
5612 assert_eq!(s.cyclomatic_sum(), 6);
5613 assert_eq!(s.cyclomatic_max(), 5);
5614 assert_eq!(s.cyclomatic_modified_sum(), 6);
5615 insta::assert_json_snapshot!(
5616 metric.cyclomatic,
5617 @r#"
5618 {
5619 "sum": 6,
5620 "value": 1,
5621 "average": 6.0,
5622 "min": 1,
5623 "max": 5,
5624 "modified": {
5625 "sum": 6,
5626 "value": 1,
5627 "average": 6.0,
5628 "min": 1,
5629 "max": 5
5630 }
5631 }
5632 "#
5633 );
5634 },
5635 );
5636 }
5637
5638 /// Switch with intentional fall-through: every `case` adds +1 standard
5639 /// regardless of whether the arm `break`s. Modified collapses all three
5640 /// arms into one switch container.
5641 #[test]
5642 fn c_switch_fallthrough() {
5643 check_metrics::<CParser>(
5644 "int f(int x) {
5645 int r = 0;
5646 switch (x) {
5647 case 1: // +1
5648 case 2: // +1
5649 r = 10;
5650 break;
5651 case 3: // +1
5652 r = 20;
5653 break;
5654 }
5655 return r;
5656 }",
5657 "foo.c",
5658 |metric| {
5659 // standard: unit(1) + fn(1) + 3 cases = 5
5660 // modified: unit(1) + fn(1) + 1 switch container = 3
5661 let s = &metric.cyclomatic;
5662 assert_eq!(s.cyclomatic_sum(), 5);
5663 assert_eq!(s.cyclomatic_modified_sum(), 3);
5664 assert!(s.cyclomatic_modified_sum() < s.cyclomatic_sum());
5665 insta::assert_json_snapshot!(
5666 metric.cyclomatic,
5667 @r#"
5668 {
5669 "sum": 5,
5670 "value": 1,
5671 "average": 5.0,
5672 "min": 1,
5673 "max": 4,
5674 "modified": {
5675 "sum": 3,
5676 "value": 1,
5677 "average": 3.0,
5678 "min": 1,
5679 "max": 2
5680 }
5681 }
5682 "#
5683 );
5684 },
5685 );
5686 }
5687
5688 /// `goto` is not a recognised decision keyword in the C-family macro
5689 /// (only `If | For | While | Catch | ConditionalExpression | && | ||`
5690 /// add complexity, plus `Case` / `SwitchStatement`). The label and the
5691 /// `goto` jump are control-flow, but the metric deliberately mirrors
5692 /// Lizard, which also does not count `goto`. This test pins that
5693 /// decision so a future change that adds `Cpp::GotoStatement` to the
5694 /// macro fires here first.
5695 #[test]
5696 fn c_goto_not_counted() {
5697 check_metrics::<CParser>(
5698 "int f(int n) {
5699 int i = 0;
5700 retry:
5701 if (i < n) { // +1
5702 ++i;
5703 goto retry; // ignored
5704 }
5705 return i;
5706 }",
5707 "foo.c",
5708 |metric| {
5709 // standard: unit(1) + fn(1) + if(1) = 3
5710 // goto/label add nothing.
5711 let s = &metric.cyclomatic;
5712 assert_eq!(s.cyclomatic_sum(), 3);
5713 assert_eq!(s.cyclomatic_modified_sum(), 3);
5714 insta::assert_json_snapshot!(
5715 metric.cyclomatic,
5716 @r#"
5717 {
5718 "sum": 3,
5719 "value": 1,
5720 "average": 3.0,
5721 "min": 1,
5722 "max": 2,
5723 "modified": {
5724 "sum": 3,
5725 "value": 1,
5726 "average": 3.0,
5727 "min": 1,
5728 "max": 2
5729 }
5730 }
5731 "#
5732 );
5733 },
5734 );
5735 }
5736
5737 /// Direct accessor coverage: assert the modified-CCN getters return
5738 /// the values we expect from a known fixture, bypassing the JSON
5739 /// serializer. Modified must never exceed standard for non-degenerate
5740 /// inputs (a switch with at least one arm).
5741 #[test]
5742 fn cyclomatic_modified_accessors() {
5743 check_metrics::<RustParser>(
5744 "fn f(x: u8) -> u8 {
5745 match x {
5746 1 => 1,
5747 2 => 2,
5748 _ => 0,
5749 }
5750 }",
5751 "foo.rs",
5752 |metric| {
5753 // standard sum: unit(1) + fn(1 + 2 arms, _ skipped) = 4
5754 // modified sum: unit(1) + fn(1 + 1 MatchExpr) = 3
5755 let s = &metric.cyclomatic;
5756 assert_eq!(s.cyclomatic_modified_sum(), 3);
5757 assert_eq!(s.cyclomatic_modified_min(), 1);
5758 assert_eq!(s.cyclomatic_modified_max(), 2);
5759 // #512: divisor is the single function space, not the two
5760 // total spaces (unit + fn), so 3 / 1 = 3.0 (was 3 / 2 = 1.5).
5761 assert_eq!(s.cyclomatic_modified_average(), 3.0);
5762 assert!(s.cyclomatic_modified_sum() <= s.cyclomatic_sum());
5763 },
5764 );
5765 }
5766
5767 /// Bare `_ =>` wildcard is not counted (matches C-family `default:`).
5768 #[test]
5769 fn rust_wildcard_only_match() {
5770 check_metrics::<RustParser>(
5771 "fn f(x: u8) -> &'static str {
5772 match x {
5773 _ => \"fallback\",
5774 }
5775 }",
5776 "foo.rs",
5777 |metric| {
5778 // standard: unit(1) + fn(1) + 0 arms (bare wildcard skipped) = 2
5779 // modified: unit(1) + fn(1) + MatchExpr(1) = 3
5780 insta::assert_json_snapshot!(
5781 metric.cyclomatic,
5782 @r#"
5783 {
5784 "sum": 2,
5785 "value": 1,
5786 "average": 2.0,
5787 "min": 1,
5788 "max": 1,
5789 "modified": {
5790 "sum": 3,
5791 "value": 1,
5792 "average": 3.0,
5793 "min": 1,
5794 "max": 2
5795 }
5796 }
5797 "#
5798 );
5799 },
5800 );
5801 }
5802
5803 /// Wildcard arm plus explicit arms: only explicit arms count.
5804 #[test]
5805 fn rust_wildcard_plus_explicit_arms() {
5806 check_metrics::<RustParser>(
5807 "fn f(x: u8) -> &'static str {
5808 match x {
5809 1 => \"one\",
5810 2 => \"two\",
5811 3 => \"three\",
5812 _ => \"other\",
5813 }
5814 }",
5815 "foo.rs",
5816 |metric| {
5817 // standard: unit(1) + fn(1) + 3 arms (1,2,3) = 5
5818 // modified: unit(1) + fn(1) + MatchExpr(1) = 3
5819 insta::assert_json_snapshot!(
5820 metric.cyclomatic,
5821 @r#"
5822 {
5823 "sum": 5,
5824 "value": 1,
5825 "average": 5.0,
5826 "min": 1,
5827 "max": 4,
5828 "modified": {
5829 "sum": 3,
5830 "value": 1,
5831 "average": 3.0,
5832 "min": 1,
5833 "max": 2
5834 }
5835 }
5836 "#
5837 );
5838 },
5839 );
5840 }
5841
5842 /// `Some(_)` is NOT a bare wildcard — still counts.
5843 #[test]
5844 fn rust_some_wildcard_still_counts() {
5845 check_metrics::<RustParser>(
5846 "fn f(x: Option<u8>) -> u8 {
5847 match x {
5848 Some(_) => 1,
5849 None => 0,
5850 }
5851 }",
5852 "foo.rs",
5853 |metric| {
5854 // standard: unit(1) + fn(1) + 2 arms (Some(_), None) = 4
5855 // modified: unit(1) + fn(1) + MatchExpr(1) = 3
5856 insta::assert_json_snapshot!(
5857 metric.cyclomatic,
5858 @r#"
5859 {
5860 "sum": 4,
5861 "value": 1,
5862 "average": 4.0,
5863 "min": 1,
5864 "max": 3,
5865 "modified": {
5866 "sum": 3,
5867 "value": 1,
5868 "average": 3.0,
5869 "min": 1,
5870 "max": 2
5871 }
5872 }
5873 "#
5874 );
5875 },
5876 );
5877 }
5878
5879 /// Tuple pattern `(_, x)` is NOT a bare wildcard — still counts.
5880 #[test]
5881 fn rust_tuple_wildcard_still_counts() {
5882 check_metrics::<RustParser>(
5883 "fn f(x: (u8, u8)) -> u8 {
5884 match x {
5885 (0, y) => y,
5886 (_, y) => y + 1,
5887 }
5888 }",
5889 "foo.rs",
5890 |metric| {
5891 // standard: unit(1) + fn(1) + 2 arms = 4
5892 // modified: unit(1) + fn(1) + MatchExpr(1) = 3
5893 insta::assert_json_snapshot!(
5894 metric.cyclomatic,
5895 @r#"
5896 {
5897 "sum": 4,
5898 "value": 1,
5899 "average": 4.0,
5900 "min": 1,
5901 "max": 3,
5902 "modified": {
5903 "sum": 3,
5904 "value": 1,
5905 "average": 3.0,
5906 "min": 1,
5907 "max": 2
5908 }
5909 }
5910 "#
5911 );
5912 },
5913 );
5914 }
5915
5916 /// `_ if guard` is NOT a bare wildcard — still counts.
5917 /// The `if` keyword inside the guard also contributes +1 standard/modified.
5918 #[test]
5919 fn rust_guarded_wildcard_still_counts() {
5920 check_metrics::<RustParser>(
5921 "fn f(x: u8) -> &'static str {
5922 match x {
5923 1 => \"one\",
5924 _ if x > 100 => \"big\",
5925 _ => \"other\",
5926 }
5927 }",
5928 "foo.rs",
5929 |metric| {
5930 // standard: unit(1) + fn(1 + arm(1) + guarded_arm(1) + if_kw(1)) = 5
5931 // modified: unit(1) + fn(1 + MatchExpr(1) + if_kw(1)) = 4
5932 insta::assert_json_snapshot!(
5933 metric.cyclomatic,
5934 @r#"
5935 {
5936 "sum": 5,
5937 "value": 1,
5938 "average": 5.0,
5939 "min": 1,
5940 "max": 4,
5941 "modified": {
5942 "sum": 4,
5943 "value": 1,
5944 "average": 4.0,
5945 "min": 1,
5946 "max": 3
5947 }
5948 }
5949 "#
5950 );
5951 },
5952 );
5953 }
5954
5955 /// Regression #107: empty case…esac has no arms, so standard adds 0 and
5956 /// modified adds 1 (the container).
5957 #[test]
5958 fn bash_case_empty() {
5959 check_metrics::<BashParser>(
5960 "#!/bin/bash
5961f() {
5962 case $1 in
5963 esac
5964}",
5965 "foo.sh",
5966 |metric| {
5967 // standard: unit(1) + fn(1) + 0 arms = 2
5968 // modified: unit(1) + fn(1) + case_stmt(1) = 3
5969 insta::assert_json_snapshot!(
5970 metric.cyclomatic,
5971 @r#"
5972 {
5973 "sum": 2,
5974 "value": 1,
5975 "average": 2.0,
5976 "min": 1,
5977 "max": 1,
5978 "modified": {
5979 "sum": 3,
5980 "value": 1,
5981 "average": 3.0,
5982 "min": 1,
5983 "max": 2
5984 }
5985 }
5986 "#
5987 );
5988 },
5989 );
5990 }
5991
5992 /// Regression #107: nested case…esac — each container contributes to
5993 /// modified independently, and each arm contributes to standard.
5994 #[test]
5995 fn bash_nested_case() {
5996 check_metrics::<BashParser>(
5997 "#!/bin/bash
5998f() {
5999 case $1 in
6000 a)
6001 case $2 in
6002 x) echo ax ;;
6003 y) echo ay ;;
6004 esac
6005 ;;
6006 b) echo b ;;
6007 esac
6008}",
6009 "foo.sh",
6010 |metric| {
6011 // standard: unit(1) + fn(1) + outer arms(a,b = 2) + inner arms(x,y = 2) = 6
6012 // modified: unit(1) + fn(1) + 2 case_stmts = 4
6013 insta::assert_json_snapshot!(
6014 metric.cyclomatic,
6015 @r#"
6016 {
6017 "sum": 6,
6018 "value": 1,
6019 "average": 6.0,
6020 "min": 1,
6021 "max": 5,
6022 "modified": {
6023 "sum": 4,
6024 "value": 1,
6025 "average": 4.0,
6026 "min": 1,
6027 "max": 3
6028 }
6029 }
6030 "#
6031 );
6032 },
6033 );
6034 }
6035
6036 /// Nested matches with wildcards: only bare `_` skipped at each level.
6037 #[test]
6038 fn rust_nested_match_with_wildcards() {
6039 check_metrics::<RustParser>(
6040 "fn f(x: u8, y: u8) -> &'static str {
6041 match x {
6042 1 => match y {
6043 1 => \"one-one\",
6044 _ => \"one-other\",
6045 },
6046 _ => \"other\",
6047 }
6048 }",
6049 "foo.rs",
6050 |metric| {
6051 // standard: unit(1) + fn(1) + outer arm 1(+1) + inner arm 1(+1)
6052 // + outer bare _(0) + inner bare _(0) = 4
6053 // modified: unit(1) + fn(1) + 2 MatchExpr(+2) = 4
6054 insta::assert_json_snapshot!(
6055 metric.cyclomatic,
6056 @r#"
6057 {
6058 "sum": 4,
6059 "value": 1,
6060 "average": 4.0,
6061 "min": 1,
6062 "max": 3,
6063 "modified": {
6064 "sum": 4,
6065 "value": 1,
6066 "average": 4.0,
6067 "min": 1,
6068 "max": 3
6069 }
6070 }
6071 "#
6072 );
6073 },
6074 );
6075 }
6076
6077 #[test]
6078 fn ruby_nested_branches() {
6079 // expected: unit(1) + method(1 + `if` + `while`) = 1 + 3 = 4
6080 // standard CCN.
6081 check_metrics::<RubyParser>(
6082 "def foo(a)\n if a > 0\n while a > 0\n a -= 1\n end\n end\nend\n",
6083 "foo.rb",
6084 |metric| {
6085 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
6086 insta::assert_json_snapshot!(metric.cyclomatic);
6087 },
6088 );
6089 }
6090
6091 #[test]
6092 fn ruby_case_when_arms() {
6093 // Each `when` arm adds standard CCN; the `case` container is
6094 // counted ONCE in modified CCN.
6095 // expected: standard = unit(1) + method(1 + 3 when) = 5;
6096 // modified = unit(1) + method(1 + 1 case) = 3.
6097 check_metrics::<RubyParser>(
6098 "def foo(x)\n case x\n when 1 then 'one'\n when 2 then 'two'\n when 3 then 'three'\n end\nend\n",
6099 "foo.rb",
6100 |metric| {
6101 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
6102 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
6103 insta::assert_json_snapshot!(metric.cyclomatic);
6104 },
6105 );
6106 }
6107
6108 #[test]
6109 fn ruby_case_match_default_only_arm_not_counted() {
6110 // Regression for #977: a `case … in` whose only arm is the bare
6111 // wildcard `in _` (no guard) is a default-only match and must add
6112 // NO standard decision — mirroring Rust's bare-`_` `MatchArm` and
6113 // Python's `case _:` filters. The `case_match` container still
6114 // contributes one modified decision.
6115 // expected per function: standard = 1 (base) + 0 = 1;
6116 // modified = 1 (base) + 1 (case_match) = 2.
6117 check_metrics::<RubyParser>(
6118 "def f(x)\n case x\n in _ then :default\n end\nend\n",
6119 "foo.rb",
6120 |metric| {
6121 assert_eq!(metric.cyclomatic.cyclomatic_max(), 1);
6122 assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 2);
6123 },
6124 );
6125 }
6126
6127 #[test]
6128 fn ruby_case_match_in_arms_and_guard_counted() {
6129 // Regression for #977: a non-wildcard `in 1` arm and a guarded
6130 // wildcard `in _ if x > 0` arm each add one standard decision,
6131 // while the trailing bare `in _` default arm adds none. The
6132 // `case_match` container stays a modified-only decision.
6133 // expected per function: standard = 1 (base) + `in 1` + `in _ if`
6134 // = 3; modified = 1 (base) + 1 (case_match) = 2.
6135 check_metrics::<RubyParser>(
6136 "def f(x)\n case x\n in 1 then :one\n in _ if x > 0 then :positive\n in _ then :default\n end\nend\n",
6137 "foo.rb",
6138 |metric| {
6139 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
6140 assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 2);
6141 },
6142 );
6143 }
6144
6145 /// Cross-language parity for default-arm filtering (#977): a
6146 /// match/switch whose single arm is the bare wildcard must score the
6147 /// same per-function cyclomatic across Ruby `case … in`, Rust `match`,
6148 /// and Python `match`. Each language's catch-all arm is its
6149 /// `default:`-equivalent and adds no standard decision, so every
6150 /// function is just its base 1. Per-language snapshot suites pin each
6151 /// history but cannot catch the cross-language disagreement this
6152 /// guards (lesson 11; #106 was exactly a wildcard-counting drift).
6153 #[test]
6154 fn cyclomatic_bare_wildcard_default_arm_cross_language() {
6155 check_metrics::<RubyParser>(
6156 "def f(x)\n case x\n in _ then :default\n end\nend\n",
6157 "foo.rb",
6158 |m| assert_eq!(m.cyclomatic.cyclomatic_max(), 1, "ruby"),
6159 );
6160 check_metrics::<RustParser>(
6161 "fn f(x: i32) -> i32 {\n match x {\n _ => 0,\n }\n}\n",
6162 "foo.rs",
6163 |m| assert_eq!(m.cyclomatic.cyclomatic_max(), 1, "rust"),
6164 );
6165 check_metrics::<PythonParser>(
6166 "def f(x):\n match x:\n case _:\n return 0\n",
6167 "foo.py",
6168 |m| assert_eq!(m.cyclomatic.cyclomatic_max(), 1, "python"),
6169 );
6170 }
6171
6172 #[test]
6173 fn ruby_ternary_conditional() {
6174 // Ruby's `cond ? a : b` parses as `Conditional` and counts as a
6175 // branch in both standard and modified CCN.
6176 // expected: standard = unit(1) + method(1 + 1) = 3.
6177 check_metrics::<RubyParser>(
6178 "def foo(x)\n x.positive? ? :pos : :nonpos\nend\n",
6179 "foo.rb",
6180 |metric| {
6181 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
6182 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
6183 },
6184 );
6185 }
6186
6187 #[test]
6188 fn ruby_and_or_keywords() {
6189 // Word-form `and` / `or` are distinct grammar kinds from
6190 // `&&` / `||` and must each contribute one decision point.
6191 // expected: standard = unit(1) + method(1 + and + or) = 4.
6192 check_metrics::<RubyParser>(
6193 "def foo(a, b, c)\n a and b or c\nend\n",
6194 "foo.rb",
6195 |metric| {
6196 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
6197 },
6198 );
6199 }
6200
6201 /// Cross-language parity for cyclomatic: an `if/else if/else` chain
6202 /// of three arms must produce the same per-function (max-space)
6203 /// cyclomatic score across Ruby, Rust, and Java. Per-language
6204 /// snapshot tests pin each language's history but cannot detect
6205 /// drift on the same logical construct — lesson 11
6206 /// (`docs/development/lessons_learned.md`) catalogues real
6207 /// incidents (#106 Rust-vs-C-family wildcard counting; #107 Bash
6208 /// double-counting case containers) that survived per-language
6209 /// suites for years. `cyclomatic_max()` is the function-level
6210 /// cyclomatic and is independent of unit/class space stacking, so
6211 /// the comparison is meaningful across languages with different
6212 /// space hierarchies.
6213 ///
6214 /// Expected per function: 1 (base) + 1 (`if`) + 1 (`else if`) = 3.
6215 /// The `else` arm is unconditional and does not contribute. Each
6216 /// language asserts the literal 3.0 in its own closure so a future
6217 /// drift in any single language fails THIS test (and only this
6218 /// test), making cross-language disagreement visible at a glance.
6219 #[test]
6220 fn cyclomatic_if_elseif_else_chain_cross_language() {
6221 check_metrics::<RubyParser>(
6222 "def classify(x)\n if x > 0\n :pos\n elsif x < 0\n :neg\n else\n :zero\n end\nend\n",
6223 "foo.rb",
6224 |m| {
6225 assert_eq!(m.cyclomatic.cyclomatic_max(), 3, "ruby");
6226 },
6227 );
6228 check_metrics::<RustParser>(
6229 "fn classify(x: i32) -> &'static str {\n if x > 0 { \"pos\" } else if x < 0 { \"neg\" } else { \"zero\" }\n}\n",
6230 "foo.rs",
6231 |m| {
6232 assert_eq!(m.cyclomatic.cyclomatic_max(), 3, "rust");
6233 },
6234 );
6235 check_metrics::<JavaParser>(
6236 "class C {\n String classify(int x) {\n if (x > 0) return \"pos\";\n else if (x < 0) return \"neg\";\n else return \"zero\";\n }\n}\n",
6237 "Foo.java",
6238 |m| {
6239 assert_eq!(m.cyclomatic.cyclomatic_max(), 3, "java");
6240 },
6241 );
6242 }
6243
6244 /// Parity gate for the `impl_cyclomatic_java_like!` macro (#300):
6245 /// every decision kind shared by Java and Groovy must produce the
6246 /// same per-function cyclomatic score for a common decision-rich
6247 /// method body. Dropping a kind from the macro body (e.g.,
6248 /// removing `For` or `TernaryExpression`) would fail BOTH language
6249 /// assertions; dropping a kind from only one invocation would fail
6250 /// only that language's assertion.
6251 ///
6252 /// The body intentionally exercises every shared kind:
6253 /// `If`, `For`, `While`, `Catch`, `TernaryExpression`, `AMPAMP`,
6254 /// `PIPEPIPE`, plus a `switch` with two `Case` arms (one is the
6255 /// default and contributes nothing under standard CCN). Expected
6256 /// per-function: 1 (base) + if + for + while + catch + ternary +
6257 /// && + || + 2 cases = 10 (standard).
6258 ///
6259 /// Modified CCN is asserted in parallel: the multi-kind arm
6260 /// bumps both counters, and `Switch` (one keyword token per
6261 /// switch construct) replaces the standard CCN's two `Case`
6262 /// arms. Expected modified per-function: 1 (base) + if + for +
6263 /// while + catch + ternary + && + || + switch = 9. Without the
6264 /// modified assertion a mutation that drops
6265 /// `stats.cyclomatic_modified += 1.` from any shared arm (or
6266 /// drops the `Switch` arm entirely) would pass.
6267 #[test]
6268 fn cyclomatic_java_groovy_parity_300() {
6269 const JAVA_SRC: &str = "class C {\n\
6270 int decide(int x, int y, int[] xs) {\n\
6271 int r = 0;\n\
6272 if (x > 0 && y > 0) r = 1;\n\
6273 for (int i = 0; i < 3; i++) r++;\n\
6274 while (x > 0) { x--; r++; }\n\
6275 try { r += xs[0]; } catch (Exception e) { r = -1; }\n\
6276 r = (x > 0 || y < 0) ? r : -r;\n\
6277 switch (x) { case 1: r++; break; case 2: r--; break; default: break; }\n\
6278 return r;\n\
6279 }\n\
6280 }\n";
6281 const GROOVY_SRC: &str = "class C {\n\
6282 int decide(int x, int y, int[] xs) {\n\
6283 int r = 0\n\
6284 if (x > 0 && y > 0) r = 1\n\
6285 for (int i = 0; i < 3; i++) r++\n\
6286 while (x > 0) { x--; r++ }\n\
6287 try { r += xs[0] } catch (Exception e) { r = -1 }\n\
6288 r = (x > 0 || y < 0) ? r : -r\n\
6289 switch (x) { case 1: r++; break; case 2: r--; break; default: break }\n\
6290 return r\n\
6291 }\n\
6292 }\n";
6293 check_metrics::<JavaParser>(JAVA_SRC, "Foo.java", |m| {
6294 assert_eq!(m.cyclomatic.cyclomatic_max(), 10, "java parity");
6295 assert_eq!(
6296 m.cyclomatic.cyclomatic_modified_max(),
6297 9,
6298 "java modified parity"
6299 );
6300 });
6301 check_metrics::<GroovyParser>(GROOVY_SRC, "foo.groovy", |m| {
6302 assert_eq!(m.cyclomatic.cyclomatic_max(), 10, "groovy parity");
6303 assert_eq!(
6304 m.cyclomatic.cyclomatic_modified_max(),
6305 9,
6306 "groovy modified parity"
6307 );
6308 });
6309 }
6310
6311 /// Groovy-only delta in `impl_cyclomatic_java_like!`: the `Assert`
6312 /// extra-kind invocation must keep Groovy's `assert` branching at
6313 /// +1 while Java does not count anything for an identical-looking
6314 /// construct (Java has no `assert`-as-branch token; its `assert`
6315 /// statement is grammar-distinct and not in this macro's arm).
6316 /// Dropping `[Assert]` from the Groovy invocation would fail this
6317 /// test.
6318 #[test]
6319 fn cyclomatic_groovy_assert_arm_300() {
6320 check_metrics::<GroovyParser>("void check(int x) { assert x > 0 }", "foo.groovy", |m| {
6321 // unit(1) + fn(1) + assert(1) = 3
6322 assert_eq!(m.cyclomatic.cyclomatic_sum(), 3, "groovy assert sum");
6323 assert_eq!(m.cyclomatic.cyclomatic_max(), 2, "groovy assert max");
6324 // Assert contributes to BOTH standard and modified CCN, so the
6325 // fn-level modified score is also base(1) + assert(1) = 2.
6326 // Without this assertion, a mutation that dropped
6327 // `stats.cyclomatic_modified += 1.` from the multi-kind arm
6328 // would pass.
6329 assert_eq!(
6330 m.cyclomatic.cyclomatic_modified_max(),
6331 2,
6332 "groovy assert modified max"
6333 );
6334 });
6335 }
6336
6337 /// Regression for issue #246: Groovy's Elvis operator `?:` is a
6338 /// short-circuit nullish operator that introduces a branch — each
6339 /// occurrence in a chain adds +1 to cyclomatic complexity. The
6340 /// dekobon Groovy grammar models Elvis as a distinct
6341 /// `elvis_expression` node with a real `QMARKCOLON` token, so the
6342 /// `impl_cyclomatic_java_like!(GroovyCode, Groovy, [Assert,
6343 /// QMARKCOLON])` invocation picks it up directly.
6344 #[test]
6345 fn cyclomatic_groovy_elvis_chain_246() {
6346 check_metrics::<GroovyParser>(
6347 "def pick(a, b, c) { return a ?: b ?: c }",
6348 "foo.groovy",
6349 |m| {
6350 // unit(1) + fn(1) + two `?:` short-circuits(2) = 4
6351 assert_eq!(m.cyclomatic.cyclomatic_sum(), 4, "groovy elvis sum");
6352 assert_eq!(m.cyclomatic.cyclomatic_max(), 3, "groovy elvis max");
6353 assert_eq!(
6354 m.cyclomatic.cyclomatic_modified_max(),
6355 3,
6356 "groovy elvis modified max"
6357 );
6358 },
6359 );
6360 }
6361
6362 #[test]
6363 fn ruby_rescue_modifier() {
6364 // Postfix `x rescue y` parses as a `RescueModifier` node that
6365 // wraps the recovery clause. Both wrapper and clause fire the
6366 // cyclomatic branch arm; the method body therefore contributes
6367 // +2 to its space.
6368 // expected: standard = unit(1) + method(1 + 1) = 3.
6369 check_metrics::<RubyParser>(
6370 "def foo\n parse(x) rescue nil\nend\n",
6371 "foo.rb",
6372 |metric| {
6373 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
6374 insta::assert_json_snapshot!(metric.cyclomatic);
6375 },
6376 );
6377 }
6378
6379 #[test]
6380 fn ruby_safe_navigation_cyclomatic() {
6381 // Issue #452: Ruby's safe-navigation `&.` (AMPDOT) is a
6382 // short-circuit decision point per link, mirroring the
6383 // Kotlin/PHP/JS/C# treatment of `?.` (#281). The chain
6384 // `a&.b&.c` adds +2 to both standard and modified CCN.
6385 check_metrics::<RubyParser>("def read(a); a&.b&.c; end\n", "foo.rb", |metric| {
6386 // unit(1) + method(base 1 + &. 1 + &. 1) = sum 4, max 3.
6387 let s = &metric.cyclomatic;
6388 assert_eq!(s.cyclomatic_sum(), 4);
6389 assert_eq!(s.cyclomatic_max(), 3);
6390 assert_eq!(s.cyclomatic_modified_sum(), 4);
6391 assert_eq!(s.cyclomatic_modified_max(), 3);
6392 });
6393 }
6394
6395 /// Nested control flow inside a `when` handler (the iRules floor case,
6396 /// mirroring `rust_1_level_nesting`). unit(1) + handler(base 1 + while 1
6397 /// + if 1 = 3) = sum 4, max 3.
6398 #[test]
6399 fn irules_1_level_nesting() {
6400 check_metrics::<IrulesParser>(
6401 "when HTTP_REQUEST {
6402 while { $x > 0 } {
6403 if { $x > 10 } {
6404 set x [expr { $x - 1 }]
6405 }
6406 }
6407}
6408",
6409 "foo.irule",
6410 |metric| {
6411 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
6412 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
6413 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
6414 },
6415 );
6416 }
6417
6418 /// iRules `switch` is a dedicated node: each non-`default` arm is one
6419 /// standard decision; the whole `switch` is one modified decision.
6420 /// standard: unit(1) + handler(base 1 + 2 arms) = 4; modified:
6421 /// unit(1) + handler(base 1 + switch 1) = 3. The `default` arm is free.
6422 #[test]
6423 fn irules_switch() {
6424 check_metrics::<IrulesParser>(
6425 "when HTTP_REQUEST {
6426 switch [HTTP::host] {
6427 a { pool pool_a }
6428 b { pool pool_b }
6429 default { pool pool_d }
6430 }
6431}
6432",
6433 "foo.irule",
6434 |metric| {
6435 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
6436 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
6437 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
6438 assert_eq!(metric.cyclomatic.cyclomatic_modified_max(), 2);
6439 },
6440 );
6441 }
6442
6443 /// The keyword logical operators `and` / `or` are decision points just
6444 /// like `&&` / `||` (iRules-specific — Tcl's grammar has no keyword
6445 /// forms). unit(1) + handler(base 1 + if 1 + and 1 + or 1 = 4) = 5.
6446 /// Guards edge case #3 / the keyword-operator arms in the impl.
6447 #[test]
6448 fn irules_and_or_keywords() {
6449 check_metrics::<IrulesParser>(
6450 "when HTTP_REQUEST {
6451 if { $a and $b or $c } {
6452 log local0. \"hit\"
6453 }
6454}
6455",
6456 "foo.irule",
6457 |metric| {
6458 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 5);
6459 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 5);
6460 assert_eq!(metric.cyclomatic.cyclomatic_max(), 4);
6461 },
6462 );
6463 }
6464
6465 /// String comparison operators (`contains`, `eq`, `matches`, …) are
6466 /// operators, NOT branches. Two of them appear here, joined by one `||`;
6467 /// only the `if` and the `||` are decisions: unit(1) + handler(base 1 +
6468 /// if 1 + `||` 1 = 3) = 4. The two string operators add 0. Guards edge
6469 /// case #4: if each string operator were wrongly counted as a branch the
6470 /// sum would be 6, so the divergence (4 vs 6) is unambiguous — it cannot
6471 /// be confused with the `if`/`||` simply being miscounted.
6472 #[test]
6473 fn irules_string_ops_not_branches() {
6474 check_metrics::<IrulesParser>(
6475 "when HTTP_REQUEST {
6476 if { [HTTP::uri] contains \"admin\" || [HTTP::host] eq \"x\" } {
6477 log local0. \"hit\"
6478 }
6479}
6480",
6481 "foo.irule",
6482 |metric| {
6483 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 4);
6484 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 4);
6485 assert_eq!(metric.cyclomatic.cyclomatic_max(), 3);
6486 },
6487 );
6488 }
6489
6490 /// A ternary `? :` in an `expr` is one decision; the bare `>` comparison
6491 /// is not. unit(1) + handler(base 1 + ternary 1 = 2) = 3.
6492 #[test]
6493 fn irules_ternary() {
6494 check_metrics::<IrulesParser>(
6495 "when HTTP_REQUEST {
6496 set y [expr { $x > 0 ? 1 : 0 }]
6497}
6498",
6499 "foo.irule",
6500 |metric| {
6501 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
6502 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
6503 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
6504 },
6505 );
6506 }
6507
6508 /// `dict for` iterates and is a loop decision; the non-looping
6509 /// `dict update` / `dict with` are excluded by the impl.
6510 /// unit(1) + handler(base 1 + dict_for 1 = 2) = 3.
6511 #[test]
6512 fn irules_dict_for_loop() {
6513 check_metrics::<IrulesParser>(
6514 "when HTTP_REQUEST {
6515 dict for { k v } $d {
6516 log local0. $k
6517 }
6518}
6519",
6520 "foo.irule",
6521 |metric| {
6522 assert_eq!(metric.cyclomatic.cyclomatic_sum(), 3);
6523 assert_eq!(metric.cyclomatic.cyclomatic_modified_sum(), 3);
6524 assert_eq!(metric.cyclomatic.cyclomatic_max(), 2);
6525 },
6526 );
6527 }
6528
6529 /// Objective-C floor: an `if` nested inside a `for` inside a
6530 /// `method_definition` held by an `@implementation`. The
6531 /// `@implementation` opens a Class space (+1). Standard CCN =
6532 /// unit(1) + class(1) + method(1) + for(1) + if(1) = 5.
6533 #[test]
6534 fn objc_nested_control() {
6535 check_metrics::<ObjcParser>(
6536 "@implementation Foo
6537- (void)bar:(NSArray *)arr {
6538 for (int i = 0; i < 10; ++i) {
6539 if (i > 5) {
6540 [self use:i];
6541 }
6542 }
6543}
6544@end
6545",
6546 "foo.m",
6547 |metric| {
6548 assert_eq!(metric.cyclomatic.cyclomatic_sum() as u32, 5);
6549 insta::assert_json_snapshot!(metric.cyclomatic, @r#"
6550 {
6551 "sum": 5,
6552 "value": 1,
6553 "average": 5.0,
6554 "min": 1,
6555 "max": 3,
6556 "modified": {
6557 "sum": 5,
6558 "value": 1,
6559 "average": 5.0,
6560 "min": 1,
6561 "max": 3
6562 }
6563 }
6564 "#);
6565 },
6566 );
6567 }
6568
6569 /// Objective-C `@try { } @catch { }`: the `catch_clause` node adds
6570 /// one decision point. Standard CCN = unit(1) + class(1) + method(1)
6571 /// + catch(1) = 4.
6572 #[test]
6573 fn objc_try_catch() {
6574 check_metrics::<ObjcParser>(
6575 "@implementation Foo
6576- (void)bar {
6577 @try {
6578 [self doWork];
6579 } @catch (NSException *e) {
6580 [self log:e];
6581 }
6582}
6583@end
6584",
6585 "foo.m",
6586 |metric| {
6587 assert_eq!(metric.cyclomatic.cyclomatic_sum() as u32, 4);
6588 insta::assert_json_snapshot!(metric.cyclomatic, @r#"
6589 {
6590 "sum": 4,
6591 "value": 1,
6592 "average": 4.0,
6593 "min": 1,
6594 "max": 2,
6595 "modified": {
6596 "sum": 4,
6597 "value": 1,
6598 "average": 4.0,
6599 "min": 1,
6600 "max": 2
6601 }
6602 }
6603 "#);
6604 },
6605 );
6606 }
6607
6608 /// Objective-C fast enumeration `for (id x in arr)` folds into a
6609 /// `for_statement` whose `for` keyword fires once, exactly like a
6610 /// classic `for`. Standard CCN = unit(1) + class(1) + method(1) +
6611 /// for(1) = 4.
6612 #[test]
6613 fn objc_fast_enumeration() {
6614 check_metrics::<ObjcParser>(
6615 "@implementation Foo
6616- (void)bar:(NSArray *)arr {
6617 for (id x in arr) {
6618 [self use:x];
6619 }
6620}
6621@end
6622",
6623 "foo.m",
6624 |metric| {
6625 assert_eq!(metric.cyclomatic.cyclomatic_sum() as u32, 4);
6626 insta::assert_json_snapshot!(metric.cyclomatic, @r#"
6627 {
6628 "sum": 4,
6629 "value": 1,
6630 "average": 4.0,
6631 "min": 1,
6632 "max": 2,
6633 "modified": {
6634 "sum": 4,
6635 "value": 1,
6636 "average": 4.0,
6637 "min": 1,
6638 "max": 2
6639 }
6640 }
6641 "#);
6642 },
6643 );
6644 }
6645}