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