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
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
#![allow(
clippy::enum_glob_use,
clippy::too_many_lines,
clippy::wildcard_imports
)]
#![allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::cast_sign_loss
)]
use super::{Abc, Stats};
use crate::macros::elixir_bool_terminal_kinds;
use crate::*;
// Elixir ABC unary-conditional walker (Fitzpatrick Rule 9; issue #557).
// tree-sitter-elixir parses `a && b || c` as a left-nested chain of
// `binary_operator` nodes (aliased `BinaryOperator`..`BinaryOperator3`
// per lesson #2) carrying `&&` / `||` / `and` / `or` operator tokens.
// Negation surfaces as `unary_operator` whose child(0) is the `!` token;
// parenthesised operands parse as `block`. Both are unwrapped by
// `elixir_inspect_container`.
fn elixir_inspect_container(container_node: &Node, conditions: &mut f64) {
use Elixir as E;
let mut node = *container_node;
let mut node_kind = node.kind_id().into();
let Some(parent) = node.parent() else { return };
let mut has_boolean_content = matches!(
parent.kind_id().into(),
E::BinaryOperator | E::BinaryOperator2 | E::BinaryOperator3
);
loop {
let is_block = matches!(node_kind, E::Block);
let is_not = matches!(node_kind, E::UnaryOperator)
&& node.child(0).is_some_and(|c| c.kind_id() == E::BANG as u16);
if !is_block && !is_not {
break;
}
if !has_boolean_content && is_not {
has_boolean_content = true;
}
// A `!` unary stores its operand at child index 1 (after the `!`
// token); a parenthesised `block` carries its inner expression as
// the first named child.
let next = if is_not {
node.child(1)
} else {
node.children().find(Node::is_named)
};
let Some(child) = next else { break };
node = child;
node_kind = node.kind_id().into();
if matches!(node_kind, elixir_bool_terminal_kinds!()) {
if has_boolean_content {
*conditions += 1.;
}
break;
}
}
}
// Counts each non-comparison operand of an Elixir `&&` / `||` chain once.
// Comparison operands are nested `binary_operator` nodes (absent from
// `elixir_bool_terminal_kinds!()`) and so contribute nothing.
fn elixir_count_unary_conditions(list_node: &Node, conditions: &mut f64) {
use Elixir as E;
let list_kind = list_node.kind_id().into();
let mut cursor = list_node.cursor();
if cursor.goto_first_child() {
loop {
let node = cursor.node();
let node_kind = node.kind_id().into();
if matches!(node_kind, elixir_bool_terminal_kinds!())
&& matches!(
list_kind,
E::BinaryOperator | E::BinaryOperator2 | E::BinaryOperator3
)
{
*conditions += 1.;
} else if node.is_named() {
elixir_inspect_container(&node, conditions);
}
if !cursor.goto_next_sibling() {
break;
}
}
}
}
impl Abc for ElixirCode {
// Elixir's pattern-match `=` is a `BinaryOperator` whose middle
// child is an `EQ` token. The same wrapper node also hosts `+=`-
// style augmented assignments, but Elixir is purely functional —
// augmented assignment does not exist in the grammar; `EQ` is the
// only assignment-shaped operator. `|>` (`PIPEGT`) is a
// BinaryOperator too but its operator token differs, so the EQ
// child check is what filters assignments from pipelines and from
// comparison operators that share the wrapper.
//
// Branches cover `|>` (the pipe operator dispatches one call per
// step) and every `Call` node (function / method / macro
// invocation). `RemoteCallWithParentheses` and `LocalCallWith*`
// variants are subordinate nodes to `Call`, so the single `Call`
// match captures every dispatch site.
//
// Conditions cover `when` (guard token `Elixir::When`), the six
// comparison operator tokens (`==`, `===`, `!=`, `!==`, `<`, `>`,
// `<=`, `>=`), and the keyword-shaped `Call`s that introduce a
// decision point (`if`, `unless`, `case`, `cond`, `with`).
// `for` / `while` are looping forms — not condition-shaped per
// the issue body's literal list — so we omit them.
//
// Limitations:
// - `case` is counted once on the container, not once per arm
// (`stab_clause`). The issue body says "conditions = case,
// cond, if, with, guard when" — i.e. one condition per
// construct, not per arm. Matches the Rust impl's "MatchExpression
// once" rule.
// - Higher-order calls like `Enum.reduce` are `RemoteCallWithParentheses`
// nodes; they are still `Call` nodes and so contribute one branch
// each, matching the issue's "branches = `|>`, function calls"
// instruction.
fn compute<'a>(node: &Node<'a>, code: &'a [u8], stats: &mut Stats) {
use Elixir as E;
match node.kind_id().into() {
// A `BinaryOperator` whose operator token is `EQ` is a
// pattern-match assignment. The grammar shape is
// `(left, operator, right)`, so the operator token is
// always at child index 1 — looking it up directly is
// O(1) vs. an `any()` scan of all children. This arm
// fires on every Elixir binary op (comparisons, pipes,
// boolean ops, arithmetic) so the constant-time check
// matters.
E::BinaryOperator | E::BinaryOperator2 | E::BinaryOperator3
if node
.child(1)
.is_some_and(|c| c.kind_id() == E::EQ as u16) =>
{
stats.assignments += 1.;
}
// `|>` pipeline operator: every step in `foo |> bar |> baz`
// is one branch (the pipe dispatches one call per step).
E::PIPEGT => {
stats.branches += 1.;
}
// Every Call (function, method, macro, sigil-call) is one
// branch — `RemoteCallWith*`, `LocalCallWith*`,
// `AnonymousCall`, and `DoubleCall` are all subordinate
// node kinds underneath the top-level `Call` wrapper, so
// matching `Call` alone captures every dispatch site.
//
// Method-defining macros (`def`/`defp`/`defmacro`/`defmacrop`)
// and module/struct/protocol declarations (`defmodule`/
// `defstruct`/`defprotocol`/`defimpl`) are *not* runtime
// dispatch and must not inflate `branches` — they parse as
// `Call` nodes because Elixir's grammar uses the same
// shape for all keyword-introduced forms. Aliasing/import
// directives (`alias`, `import`, `require`, `use`) are
// similarly declarative and excluded.
//
// Cognitive's `elixir_call_keyword` lookup is reused to
// identify the target keyword. Note: Cognitive only acts
// on a subset of these keywords (the four method-definers
// for nesting reset, plus the 7 control-flow keywords for
// +nesting); Abc's broader filter additionally drops the
// module/struct/protocol declarators and aliasing
// directives that Cognitive ignores entirely. Filter sets
// are intentionally different — both impls use the same
// helper to look up the keyword, but apply different
// policies on top.
E::Call => {
let keyword = super::cognitive::elixir_call_keyword(node, code);
let is_definition_or_directive = matches!(
keyword,
Some(
"def" | "defp" | "defmacro" | "defmacrop"
| "defmodule" | "defstruct" | "defprotocol" | "defimpl"
| "alias" | "import" | "require" | "use"
)
);
if !is_definition_or_directive {
stats.branches += 1.;
}
// Keyword-shaped control-flow Calls also contribute
// one condition.
if matches!(keyword, Some("if" | "unless" | "case" | "cond" | "with")) {
stats.conditions += 1.;
}
}
// Comparison operator tokens. `Elixir::LT` / `Elixir::GT`
// are unambiguously comparison ops here — unlike Go's
// generic-instantiation `<` / `>`, Elixir has no type
// parameter brackets that share the token.
E::EQEQ | E::EQEQEQ | E::BANGEQ | E::BANGEQEQ
| E::LT | E::GT | E::LTEQ | E::GTEQ
// Guard `when` token: introduces the guard clause of a
// function head or `case` arm.
| E::When => {
stats.conditions += 1.;
}
// Fitzpatrick Rule 9 walker: each non-comparison operand of a
// `&&` / `||` / `and` / `or` chain is one condition (issue
// #557). The short-circuit operators are not counted directly
// (cross-language policy, #395); the keyword forms `and` / `or`
// get the same treatment as `&&` / `||`. Combined with the
// `if` Call already contributing one condition, `if a && b ||
// c` reports 4 — consistent with the cyclomatic count.
E::AMPAMP | E::PIPEPIPE | E::And | E::Or => {
if let Some(parent) = node.parent() {
elixir_count_unary_conditions(&parent, &mut stats.conditions);
}
}
_ => {}
}
}
}