1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
//! `Cyclomatic` implementation for Elixir.
#![allow(clippy::wildcard_imports, clippy::enum_glob_use)]
#![allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
use super::*;
use crate::lang_helpers::elixir::{elixir_applying_operator, elixir_applying_remote_call};
use crate::metrics::elixir_decisions::{
elixir_arrow_is_with_clause, elixir_clause_is_decision, elixir_receive_dispatches,
elixir_receive_timeout_clause, elixir_when_is_guard, elixir_with_else_dispatches,
};
impl Cyclomatic for ElixirCode {
// Elixir's control-flow constructs are not distinct grammar
// productions: `if`/`unless`/`for`/`while`/`with`/`case`/`cond`/`try`
// all surface as `Call` nodes whose `target` field is an
// `Identifier` whose text spells the keyword. We must consult the
// source bytes (mirroring `impl Exit for ElixirCode`) to identify
// them.
//
// The split between standard and modified CCN mirrors the C-family
// case/switch treatment: per-arm `stab_clause` nodes contribute
// standard, while the multi-arm container Calls (`case`/`cond`/
// `try`/`receive`) contribute modified. Single-branch keyword Calls
// (`if`/`unless`/`for`/`while`) contribute to both, as do a `with`'s
// `<-` clauses and short-circuit booleans (`&&`, `||`, `and`, `or`).
fn compute<'a>(
node: &Node<'a>,
code: &'a [u8],
ancestors: Ancestors<'a, '_>,
stats: &mut Stats,
) {
// bca: suppress(cyclomatic, halstead) — kind dispatch table; each gated arm is one rule
use Elixir as E;
match node.kind_id().into() {
// Per-arm decisions: each `stab_clause` is one arm of a
// `case`/`cond`/`with`/`receive`/anonymous-fn body or a
// `rescue`/`catch` handler. Standard-only: modified counts a
// `case`/`cond`/`try`/`receive` container Call once, a `with` once
// only when its `else` dispatches (the `"with"` arm below),
// and gives a multi-clause `fn` or `reduce` nothing.
//
// The exception is the *first* `stab_clause` of an
// `anonymous_function` (`fn … -> … end`): it is the
// closure's head/definition, not a pattern-dispatch
// decision. The closure already opens its own function
// space seeded with base cyclomatic 1 (see
// `getter::elixir` → `SpaceKind::Function`), so counting the
// head clause too over-reports a trivial `fn x -> x end` as
// 2 (issue #776). Only the 2nd+ clauses of a multi-clause
// `fn` are real branches. A `for … reduce:`'s accumulator
// clauses take the same skip (#1535): a single `acc -> …`
// always matches. `case`/`cond`/`with` arms keep counting.
//
// Two further clause shapes are the construct's default arm
// and are excluded to match the sibling family — Rust's
// `_ =>`, Python's `case _:`, Ruby's `in _`, Kotlin's
// `else ->`, C#'s `_ =>`, Bash's `*)`, C-family `default:`
// (issue #1272, lesson 11): a bare `_ ->` catch-all under
// `case` / `receive` / `rescue` (a `rescue` arm's `_ ->`
// being free is a deliberate divergence from C-family
// `catch (...)` — the bare-`_` rescue form is vanishingly
// rare and the `try` container still pays modified), and
// any unguarded `true ->` directly under a `cond` container
// — shape-based, whatever the arm's position, matching
// Rust's position-blind bare-`_` rule; `true ->` under
// `case` is an ordinary pattern and keeps counting. A
// multi-clause `fn`'s trailing `_ ->` is NOT excluded: the
// head-clause skip above already grants the closure's free
// base path, so each 2nd+ clause — the bare catch-all
// included — is a real dispatch decision, keeping
// `fn 0 -> :a; _ -> :b end` in parity with the identical
// `case`. Guarded forms (`_ when g ->`, `true when g ->`)
// and named discards (`_x ->`) are real decisions and
// still count.
E::StabClause if elixir_clause_is_decision(node, code, ancestors) => {
stats.cyclomatic += 1.;
}
// A guard is a decision the construct it guards does not
// already pay for (#1454, transferring #1422's C# rule): a
// guarded clause fails two ways — the pattern does not
// match, or it matches and the guard is false — and a
// guarded function head is one alternative among the
// clauses. Both standard and modified, because no container
// collapses it: a `case`'s arms collapse into the container
// for modified, the guard on an arm does not, exactly as
// C#'s `when_clause` counts in both tiers.
//
// Elixir's ABC counted the `when` token from the start; it
// was cyclomatic that had no arm, so a guard read as a
// condition with no decision behind it. The #1422 order
// applies — fix cyclomatic, then re-derive ABC — and the
// re-derivation is that ABC's existing count is already the
// slot model (one per guard, sub-structure kept) and needs
// only this gate, which it now shares.
//
// No double count (§5): the token fires once per `when`,
// and the `binary_operator` that wraps it is not matched by
// any arm here.
E::When if elixir_when_is_guard(node, code, ancestors) => {
stats.cyclomatic += 1.;
stats.cyclomatic_modified += 1.;
}
// A `with`'s `pattern <- expr` clause either matches or
// short-circuits to the `else`, so each is a decision
// (#1535) — the Rust `if let` / `?` twin pays one in both
// tiers. The clauses run in sequence rather than being
// alternatives of one dispatch, so modified does not
// collapse them. A `for` generator's `<-` is excluded by
// the gate: the `for` already pays its loop decision.
E::LTDASH if elixir_arrow_is_with_clause(node, code, ancestors) => {
stats.cyclomatic += 1.;
stats.cyclomatic_modified += 1.;
}
// Short-circuit booleans add a decision point in both
// metrics — where applied. `&and/2` and `&||/2` only name the
// operator and decide nothing (#1534); ABC and cognitive gate
// on the same parent. A remote call naming it,
// `Kernel.||(a, b)`, applies the macro and decides like
// `a || b`; a user module's `Foo.||(a, b)` is an ordinary
// call and does not. ABC and cognitive score the `Kernel`
// call through the same predicate (#1574).
E::AMPAMP | E::PIPEPIPE | E::And | E::Or
if elixir_applying_operator(node, ancestors).is_some()
|| elixir_applying_remote_call(node, code, ancestors).is_some() =>
{
stats.cyclomatic += 1.;
stats.cyclomatic_modified += 1.;
}
// `Kernel.and(a, b)` / `Kernel.or(a, b)` name the operator
// with an `identifier` rather than a token, so the arm above
// never sees them; they expand the same short-circuit macro
// and decide as `a and b` does (#1582). Only those two names
// are found, so no other identifier passes the guard.
E::Identifier if elixir_applying_remote_call(node, code, ancestors).is_some() => {
stats.cyclomatic += 1.;
stats.cyclomatic_modified += 1.;
}
E::Call => {
if let Some(target) = node.child_by_field_name("target")
&& target.kind_id() == E::Identifier
&& let Some(name) = target.utf8_text(code)
{
match name {
// Single-branch constructs: count for both.
// There are no per-arm `stab_clause`s exposing
// themselves separately, so the Call itself
// must carry the decision point.
"if" | "unless" | "for" | "while" => {
stats.cyclomatic += 1.;
stats.cyclomatic_modified += 1.;
}
// Multi-arm containers: count once for modified
// (the container collapses to a single decision).
// Per-arm `stab_clause`s already contribute to
// standard above.
"case" | "cond" | "try" => {
stats.cyclomatic_modified += 1.;
}
// A `receive` is a `case` over the mailbox and
// pays as one (#1581), when it has message
// clauses to pick between: one holding only an
// `after` timeout is a timed wait. Standard pays
// the timeout clause only when a bare `_ ->`
// already took the one free default arm
// (`elixir_clause_is_decision`).
"receive" if elixir_receive_dispatches(node, code) => {
stats.cyclomatic_modified += 1.;
if elixir_receive_timeout_clause(node, code).is_some() {
stats.cyclomatic += 1.;
}
}
// A `with`'s decisions are its `<-` clauses,
// counted above in both tiers. What modified
// collapses is its `else`, the dispatch over
// the failed value — once, and only when that
// dispatch picks between anything (#1535).
"with" if elixir_with_else_dispatches(node, code) => {
stats.cyclomatic_modified += 1.;
}
_ => {}
}
}
}
_ => {}
}
}
}