Merge pull request #1731 from henry-hz/upstream-pr/elixir-complexity

fix(elixir): functions carry no complexity, fingerprint or line count
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-11-01 09:04:28 +01:00
commit f159b4c21d
2 files changed
+357 -2

No files matched your search

+195 -2
View File
@@ -9723,6 +9723,24 @@ static TSNode elixir_call_args(TSNode node) {
return args;
}
/* Combine one folded clause's metric with the function's value so far. A clause
* whose walk could not finish reports CBM_WALK_METRIC_UNAVAILABLE, and so does
* the folded function: a sum that skipped that clause would read low on exactly
* the largest functions. */
static int elixir_fold_metric_sum(int acc, int clause) {
if (acc < 0 || clause < 0) {
return CBM_WALK_METRIC_UNAVAILABLE;
}
return acc + clause;
}
static int elixir_fold_metric_max(int acc, int clause) {
if (acc < 0 || clause < 0) {
return CBM_WALK_METRIC_UNAVAILABLE;
}
return clause > acc ? clause : acc;
}
// Fold one more clause of an Elixir function into the def already pushed for
// the previous clause. Every clause is its own `def` call, and an Elixir QN
// carries neither module nor arity, so all of them compute one qualified name
@@ -9801,9 +9819,10 @@ static TSNode elixir_call_args(TSNode node) {
// name, where the pre-patch last-clause-wins record reported a publicly
// callable name as unexported. Verified against the source in every one: the
// new value is the correct one.
// `cx` is this clause's own complexity; a folded clause adds it to the function.
// Returns true when the clause folded.
static bool fold_elixir_clause(CBMExtractCtx *ctx, const char *qn, TSNode scope, TSNode node,
uint32_t end_line, bool is_exported) {
uint32_t end_line, bool is_exported, const cbm_complexity_t *cx) {
if (!qn || ctx->result->defs.count <= 0 || ts_node_is_null(scope)) {
return false;
}
@@ -9828,9 +9847,168 @@ static bool fold_elixir_clause(CBMExtractCtx *ctx, const char *qn, TSNode scope,
prev->end_line = end_line;
}
prev->is_exported = prev->is_exported || is_exported;
/* One node now stands for every clause, so its metrics cover every clause
* rather than the first: branch and loop counts add up, the nesting depths
* take the deepest clause, and each clause past the first is one more
* dispatch decision. A multi-clause function with no branch in any body is
* still a decision tree over its heads. `lines` follows the widened span. */
int cyclomatic = elixir_fold_metric_sum(prev->complexity, cx->cyclomatic);
prev->complexity = cyclomatic < 0 ? cyclomatic : cyclomatic + 1;
prev->cognitive = elixir_fold_metric_sum(prev->cognitive, cx->cognitive);
prev->loop_count = elixir_fold_metric_sum(prev->loop_count, cx->loop_count);
prev->loop_depth = elixir_fold_metric_max(prev->loop_depth, cx->loop_depth);
prev->max_access_depth = elixir_fold_metric_max(prev->max_access_depth, cx->max_access_depth);
prev->lines = (int)(prev->end_line - prev->start_line + TS_LINE_OFFSET);
return true;
}
/* True when node `n` spans exactly the text `lit`. Avoids an arena allocation
* per node in the complexity walk, which visits every node of every def. */
static bool elixir_node_text_is(TSNode n, const char *source, const char *lit) {
if (ts_node_is_null(n)) {
return false;
}
uint32_t s = ts_node_start_byte(n);
uint32_t e = ts_node_end_byte(n);
size_t len = strlen(lit);
return (size_t)(e - s) == len && strncmp(source + s, lit, len) == 0;
}
/* Elixir has no branching NODE TYPES. `if`, `unless`, `case`, `cond`, `with`,
* `for`, `try` and `receive` are ordinary `call` nodes told apart only by the
* identifier in callee position, and every case/cond/fn/rescue arm is a
* `stab_clause`. lang_specs.c cannot express that — the Elixir branch table is
* `{"call"}`, which would score every function call as a decision — and
* cbm_compute_complexity matches on node type, so the generic path is unusable
* and every Elixir function shipped complexity 0.
*
* Counted here: the branching construct itself, every `stab_clause`, the
* short-circuit operators, and `when` guards. Container AND arm are both
* counted, which is the convention the generic tables already use — Go counts
* `expression_switch_statement` plus each `expression_case`, Python counts
* `try_statement` plus each `except_clause`. Cognitive weighting is Campbell's,
* identical to the generic walk (+1 plus the current nesting depth). */
static bool elixir_is_branch_call(TSNode call, const char *source) {
if (ts_node_child_count(call) == 0) {
return false;
}
TSNode callee = ts_node_child(call, 0);
if (ts_node_is_null(callee) || strcmp(ts_node_type(callee), "identifier") != 0) {
return false;
}
return elixir_node_text_is(callee, source, "if") ||
elixir_node_text_is(callee, source, "unless") ||
elixir_node_text_is(callee, source, "with") ||
elixir_node_text_is(callee, source, "for") ||
elixir_node_text_is(callee, source, "case") ||
elixir_node_text_is(callee, source, "cond") ||
elixir_node_text_is(callee, source, "try") ||
elixir_node_text_is(callee, source, "receive");
}
/* A comprehension (`for x <- xs, do: ...`) is Elixir's only real loop node.
* Enum.map/reduce iterate too, but they are plain function calls; counting them
* would make loop_depth meaningless, so they are left out. */
static bool elixir_is_loop_call(TSNode call, const char *source) {
if (ts_node_child_count(call) == 0) {
return false;
}
TSNode callee = ts_node_child(call, 0);
return !ts_node_is_null(callee) && strcmp(ts_node_type(callee), "identifier") == 0 &&
elixir_node_text_is(callee, source, "for");
}
static bool elixir_is_branch_operator(TSNode node, const char *source) {
TSNode op = ts_node_child_by_field_name(node, TS_FIELD("operator"));
return elixir_node_text_is(op, source, "&&") || elixir_node_text_is(op, source, "||") ||
elixir_node_text_is(op, source, "and") || elixir_node_text_is(op, source, "or") ||
elixir_node_text_is(op, source, "when");
}
/* The pending frames live on the same growable stack as cbm_compute_complexity:
* a small on-stack first chunk that spills to the heap through
* cbm_walk_stack_reserve, so a wide body is never truncated and no thread pays
* for a large fixed array. If the stack cannot grow, every metric reports
* CBM_WALK_METRIC_UNAVAILABLE instead of a silently low count. */
static void elixir_compute_complexity(TSNode node, const char *source, cbm_complexity_t *out) {
enum { ELIXIR_CX_STACK_INLINE = 512 };
struct elixir_cx_frame {
TSNode node;
int bdepth;
int ldepth;
int adepth;
};
struct elixir_cx_frame inline_stack[ELIXIR_CX_STACK_INLINE];
struct elixir_cx_frame *stack = inline_stack;
int cap = ELIXIR_CX_STACK_INLINE;
memset(out, 0, sizeof(*out));
int top = 0;
stack[top].node = node;
stack[top].bdepth = 0;
stack[top].ldepth = 0;
stack[top].adepth = 0;
top++;
while (top > 0) {
int cur = --top;
TSNode n = stack[cur].node;
int bdepth = stack[cur].bdepth;
int ldepth = stack[cur].ldepth;
int adepth = stack[cur].adepth;
const char *kind = ts_node_type(n);
bool is_branch = false;
if (strcmp(kind, "stab_clause") == 0) {
is_branch = true;
} else if (strcmp(kind, "call") == 0) {
is_branch = elixir_is_branch_call(n, source);
} else if (strcmp(kind, "binary_operator") == 0) {
is_branch = elixir_is_branch_operator(n, source);
}
int child_b = bdepth;
int child_l = ldepth;
/* `conn.assigns.user` nests as dot(dot(conn)), so consecutive `dot`
* nodes deepen the chain and anything else resets it. */
int child_a = 0;
if (strcmp(kind, "dot") == 0) {
child_a = adepth + 1;
if (child_a > out->max_access_depth) {
out->max_access_depth = child_a;
}
}
if (is_branch) {
out->cyclomatic++;
out->cognitive += 1 + bdepth;
child_b = bdepth + 1;
}
if (strcmp(kind, "call") == 0 && elixir_is_loop_call(n, source)) {
out->loop_count++;
int d = ldepth + 1;
if (d > out->loop_depth) {
out->loop_depth = d;
}
child_l = d;
}
uint32_t nc = ts_node_child_count(n);
if (!cbm_walk_stack_reserve((void **)&stack, &cap, top + (int)nc,
sizeof(struct elixir_cx_frame), inline_stack,
"elixir_compute_complexity")) {
out->cyclomatic = CBM_WALK_METRIC_UNAVAILABLE;
out->cognitive = CBM_WALK_METRIC_UNAVAILABLE;
out->loop_count = CBM_WALK_METRIC_UNAVAILABLE;
out->loop_depth = CBM_WALK_METRIC_UNAVAILABLE;
out->max_access_depth = CBM_WALK_METRIC_UNAVAILABLE;
break;
}
for (int i = (int)nc - 1; i >= 0; i--) {
stack[top].node = ts_node_child(n, (uint32_t)i);
stack[top].bdepth = child_b;
stack[top].ldepth = child_l;
stack[top].adepth = child_a;
top++;
}
}
cbm_walk_stack_release(stack, inline_stack);
}
// Handle Elixir def/defp/defmacro — extract function definition. `scope` is the
// module body the previous clause was extracted from, and is updated to this
// def's own module body; see fold_elixir_clause for what it bounds.
@@ -9872,7 +10050,9 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char
uint32_t start_line = ts_node_start_point(node).row + TS_LINE_OFFSET;
uint32_t end_line = ts_node_end_point(node).row + TS_LINE_OFFSET;
bool is_exported = (strcmp(macro, "def") == 0 || strcmp(macro, "defmacro") == 0);
bool folded = fold_elixir_clause(ctx, qn, *scope, node, end_line, is_exported);
cbm_complexity_t cx;
elixir_compute_complexity(node, ctx->source, &cx);
bool folded = fold_elixir_clause(ctx, qn, *scope, node, end_line, is_exported, &cx);
*scope = ts_node_parent(node);
if (folded) {
return;
@@ -9887,6 +10067,19 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char
def.start_line = start_line;
def.end_line = end_line;
def.is_exported = is_exported;
def.lines = (int)(def.end_line - def.start_line + TS_LINE_OFFSET);
def.complexity = cx.cyclomatic;
def.cognitive = cx.cognitive;
def.loop_count = cx.loop_count;
def.loop_depth = cx.loop_depth;
def.max_access_depth = cx.max_access_depth;
/* Fingerprint off the body, not the whole def call: including the head
* would make every clause of a name look alike. Elixir was the only
* language emitting Functions with no fingerprint at all, so it never
* appeared in similarity or duplicate results. Runs after `lines` — the
* AST profile reads it. */
TSNode fp_body = cbm_find_child_by_kind(node, "do_block");
compute_fingerprint(ctx, &def, ts_node_is_null(fp_body) ? node : fp_body);
cbm_defs_push(&ctx->result->defs, a, def);
}
+162
View File
@@ -2736,6 +2736,165 @@ TEST(elixir_channels_pubsub_and_handle_in) {
PASS();
}
/* Elixir control flow is `call` nodes and `stab_clause` arms, not branch node
* TYPES, so the generic complexity walk scored every Elixir function 0 and the
* fingerprint was never computed at all — no similarity or duplicate detection. */
TEST(elixir_complexity_and_fingerprint) {
CBMFileResult *r = extract("defmodule Cx do\n"
" def classify(x) do\n"
" case x do\n"
" 0 -> :zero\n"
" n when n > 0 -> :pos\n"
" _ -> :neg\n"
" end\n"
" end\n"
" def plain(a), do: a\n"
" def summarize(orders, currency) do\n"
" totals = Enum.map(orders, fn order -> order.amount * order.rate end)\n"
" count = length(totals)\n"
" sum = Enum.reduce(totals, 0, fn value, acc -> acc + value end)\n"
" average = if count > 0, do: sum / count, else: 0\n"
" label = String.upcase(currency) <> \" total\"\n"
" %{label: label, sum: sum, count: count, average: average}\n"
" end\n"
" def tally(items, unit) do\n"
" values = Enum.map(items, fn item -> item.price * item.qty end)\n"
" n = length(values)\n"
" total = Enum.reduce(values, 0, fn v, a -> a + v end)\n"
" mean = if n > 0, do: total / n, else: 0\n"
" tag = String.upcase(unit) <> \" total\"\n"
" %{tag: tag, total: total, n: n, mean: mean}\n"
" end\n"
"end\n",
CBM_LANG_ELIXIR, "t", "cx.ex");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
CBMDefinition *cls = NULL;
CBMDefinition *plain = NULL;
CBMDefinition *summarize = NULL;
CBMDefinition *tally = NULL;
for (int i = 0; i < r->defs.count; i++) {
if (strcmp(r->defs.items[i].name, "classify") == 0)
cls = &r->defs.items[i];
if (strcmp(r->defs.items[i].name, "plain") == 0)
plain = &r->defs.items[i];
if (strcmp(r->defs.items[i].name, "summarize") == 0)
summarize = &r->defs.items[i];
if (strcmp(r->defs.items[i].name, "tally") == 0)
tally = &r->defs.items[i];
}
/* A body past the MinHash size gate gets a fingerprint (Elixir used to emit
* none at all); a one-expression body stays below the gate and gets none. */
ASSERT_NOT_NULL(summarize);
ASSERT(summarize->fingerprint_k > 0);
ASSERT_NOT_NULL(summarize->fingerprint);
/* Duplicate detection is what the fingerprint exists for: the same body
* shape under other function and variable names gets the same fingerprint. */
ASSERT_NOT_NULL(tally);
ASSERT_EQ(summarize->fingerprint_k, tally->fingerprint_k);
ASSERT_NOT_NULL(tally->fingerprint);
ASSERT(memcmp(summarize->fingerprint, tally->fingerprint,
(size_t)summarize->fingerprint_k * sizeof(summarize->fingerprint[0])) == 0);
ASSERT_NOT_NULL(cls);
/* The `case` itself + its 3 arms + the second arm's own `when` guard. */
ASSERT_EQ(5, cls->complexity);
ASSERT(cls->cognitive > 0);
ASSERT(cls->lines > 0);
/* A straight-line body has no decisions but still gets a line count — a
* language must not become nonzero everywhere just by being measured. */
ASSERT_NOT_NULL(plain);
ASSERT_EQ(0, plain->complexity);
ASSERT(plain->lines > 0);
ASSERT_EQ(0, plain->fingerprint_k);
cbm_free_result(r);
PASS();
}
/* The clauses of a multi-clause function fold into one Function node, so that
* node's metrics must cover every clause, not just the first: branch, cognitive
* and loop counts add up, loop_depth and max_access_depth take the deepest
* clause, every clause past the first is one more dispatch decision, and
* `lines` is the folded span. `single` repeats the second clause on its own as
* the per-clause reference: it is not folded, so it carries no dispatch charge. */
TEST(elixir_folded_clause_metrics_cover_every_clause) {
CBMFileResult *r = extract("defmodule Route do\n" /* 1 */
" def pick(0), do: Map.new()\n" /* 2 */
" def pick(n) when n > 0 do\n" /* 3 */
" for x <- [n], do: Map.new([{x, x}])\n" /* 4 */
" end\n" /* 5 */
" def pick(rows) do\n" /* 6 */
" for row <- rows, do: (for x <- row, do: x)\n" /* 7 */
" end\n" /* 8 */
" def single(n) when n > 0 do\n" /* 9 */
" for x <- [n], do: Map.new([{x, x}])\n" /* 10 */
" end\n" /* 11 */
"end\n", /* 12 */
CBM_LANG_ELIXIR, "t", "route.ex");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_EQ(1, count_defs_named(r, "Function", "pick"));
const CBMDefinition *one = elixir_first_def(r, "single");
ASSERT_NOT_NULL(one);
/* `when` + `for`, each at nesting 0; one comprehension; Map.new is one dot */
ASSERT_EQ(2, one->complexity);
ASSERT_EQ(2, one->cognitive);
ASSERT_EQ(1, one->loop_count);
ASSERT_EQ(1, one->loop_depth);
ASSERT_EQ(1, one->max_access_depth);
ASSERT_EQ(3, one->lines);
const CBMDefinition *pick = elixir_first_def(r, "pick");
ASSERT_NOT_NULL(pick);
ASSERT_EQ(2, (int)pick->start_line);
ASSERT_EQ(8, (int)pick->end_line);
/* clauses 0 + 2 + 2, plus 2 for the two clauses past the first */
ASSERT_EQ(6, pick->complexity);
/* 0 + 2 + (1 + 2): the inner comprehension sits one level deep */
ASSERT_EQ(5, pick->cognitive);
ASSERT_EQ(3, pick->loop_count);
/* the deepest clause, not the sum of the clauses (that would be 3) */
ASSERT_EQ(2, pick->loop_depth);
/* two clauses with one dot each: the deepest is 1, the sum would be 2 */
ASSERT_EQ(1, pick->max_access_depth);
ASSERT_EQ(7, pick->lines);
cbm_free_result(r);
PASS();
}
/* The Elixir complexity walk keeps its pending nodes on the growable stack
* cbm_compute_complexity uses. A fixed 4096-frame stack dropped the FIRST
* children of any wider body (they are pushed last-to-first), so the largest
* functions silently under-reported. This body is wider than that old cap. */
enum { ELIXIR_WIDE_STMTS = 5000 };
TEST(elixir_complexity_wide_body_exceeds_old_stack_cap) {
static const char head[] = "defmodule Wide do\n def wide(x) do\n";
static const char if_stmt[] = " if x, do: :a\n";
static const char for_stmt[] = " for y <- x, do: y\n";
static const char tail[] = " end\nend\n";
size_t cap = sizeof(head) + (size_t)ELIXIR_WIDE_STMTS * sizeof(for_stmt) + sizeof(tail);
char *src = malloc(cap);
ASSERT_NOT_NULL(src);
size_t pos = (size_t)snprintf(src, cap, "%s", head);
for (int i = 0; i < ELIXIR_WIDE_STMTS; i++) {
pos += (size_t)snprintf(src + pos, cap - pos, "%s", (i % 2 == 0) ? if_stmt : for_stmt);
}
snprintf(src + pos, cap - pos, "%s", tail);
CBMFileResult *r = extract(src, CBM_LANG_ELIXIR, "t", "wide.ex");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
const CBMDefinition *d = elixir_first_def(r, "wide");
ASSERT_NOT_NULL(d);
/* every `if` and every `for` is one branching call at nesting depth 0 */
ASSERT_EQ(ELIXIR_WIDE_STMTS, d->complexity);
ASSERT_EQ(ELIXIR_WIDE_STMTS, d->cognitive);
ASSERT_EQ(ELIXIR_WIDE_STMTS / 2, d->loop_count);
ASSERT_EQ(1, d->loop_depth);
cbm_free_result(r);
free(src);
PASS();
}
/* --- Haskell --- */
TEST(haskell_function) {
CBMFileResult *r = extract("add :: Int -> Int -> Int\nadd x y = x + y\n\nmultiply :: Int -> "
@@ -14540,6 +14699,9 @@ SUITE(extraction) {
RUN_TEST(extract_elixir_guarded_def_head_scope);
RUN_TEST(extract_elixir_guarded_def_head_guard_usages);
RUN_TEST(elixir_channels_pubsub_and_handle_in);
RUN_TEST(elixir_complexity_and_fingerprint);
RUN_TEST(elixir_folded_clause_metrics_cover_every_clause);
RUN_TEST(elixir_complexity_wide_body_exceeds_old_stack_cap);
RUN_TEST(haskell_function);
RUN_TEST(haskell_infix_definition_name);
RUN_TEST(ocaml_function);