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