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