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