From c43e0e1ac02568a4217781446424de5d7f2583d6 Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:13:11 -0700 Subject: [PATCH 1/2] refactor(elixir): read a def head through its guard from one shared helper `def name(args) when guard` does not parse as a call with a guard beside it. tree-sitter-elixir builds one `when` binary_operator for the WHOLE head, and the `name(args)` call that names the function is that operator's left operand. Four walks read that same node -- the def's first argument -- and each asks a different question of it: the defs walk names the function from it; the unified walk opens the function's call scope from it; the usages walk bounds the head's bindings with it; the calls walk tells a definition head apart from an invocation by it. A private copy in one of them makes the rest disagree about what a guarded clause is, and a disagreement between two of these walks does not fail, it mints an edge: a head one walk treats as a declaration and another does not is recorded as a call to the very function being defined. Two questions, not one, so two exported entry points: cbm_elixir_def_head_unwrap_guard(first_arg) returns the head UNDER every guard, or the node unchanged when there is none. The three walks that need a name or a span take this. cbm_elixir_def_head_is(signature, node) is true for that head OR for any `when` operator above it. The calls walk takes this, because a guarded head reaches it by two routes and it sees only one node per visit: `def f(x) when g` arrives as the inner `f(x)` call, and `def f when g` has no inner call at all, so the `when` operator itself arrives. Recognising one route and not the other leaves the phantom in place for the other. Both test the operator TOKEN rather than the node kind. `def a + b` is a binary_operator head too, and peeling it would name the function after its own left parameter. This commit changes no extraction behaviour: nothing calls either helper yet. The walks are separate commits, each with its own measurement. Measured while writing the test, and it corrects a claim worth not repeating: tree-sitter-elixir nests multiple guards RIGHT-associatively. `def f(x) when is_atom(x) when is_binary(x)` parses as `when(f(x), when(is_atom(x), is_binary(x)))`, so the head is the outer operator's left operand and one peel reaches it. The peel still runs to a fixed point -- one extra token compare -- so the helper's contract is "the head under every guard" rather than "the head under the first one". tests/test_extraction.c: elixir_def_head_is_covers_the_head_and_every_guard_ above_it drives the helpers on the AST directly rather than through cbm_extract_file. It has to: the two routes into the calls walk are the pair a single-walk test cannot separate, because each walk only ever sees the node its own traversal reaches. It pins the unguarded case, both guarded shapes, the double guard, and that `def a + b` is returned unchanged with its left operand answering false. Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) --- internal/cbm/helpers.c | 87 +++++++++++++++++++++ internal/cbm/helpers.h | 20 +++++ tests/test_extraction.c | 164 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 271 insertions(+) diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 50c75bdf2e..6ddd8ceaf5 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1140,6 +1140,93 @@ const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, c return scope ? cbm_arena_sprintf(a, "%s.%s", scope, name) : name; } +/* ── Elixir def-head guards ─────────────────────────────────── */ + +/* The guard sits on the head, not beside it: `def name(args) when guard` parses + * as a single `when` binary_operator whose left operand is `name(args)`. The + * operator field is an exact anonymous token, so the check is the token type + * rather than the node kind — `def a + b` is a binary_operator head too, and + * unwrapping it would name the function after its left parameter. */ +bool cbm_elixir_is_when_guard(TSNode node) { + if (ts_node_is_null(node) || strcmp(ts_node_type(node), "binary_operator") != 0) { + return false; + } + TSNode op = ts_node_child_by_field_name(node, TS_FIELD("operator")); + return !ts_node_is_null(op) && strcmp(ts_node_type(op), "when") == 0; +} + +/* One `when` level off a def's first argument, or the node unchanged. + * + * There is deliberately no named-child fallback for a missing `left`. An infix + * operator cannot reduce without a left operand, so tree-sitter-elixir never + * builds a `when` binary_operator that lacks one: measured over every guarded + * shape in the suite plus a corpus of truncated heads (`def when true`, + * `def f(x) when`, `def f(x) when when is_x(x)`, `def when() when when`), + * `left` was present on all 66 unwraps, ERROR-recovered parses included, and + * every one had the left operand as named child 0. Where the left operand was + * genuinely absent the grammar produced no `when` operator at all -- it read + * `when` as the head identifier -- so the unwrap is never entered. A + * named-child fallback would therefore be dead on a well-formed tree and, on + * the hypothetical malformed one, would hand back named child 0 without + * knowing it is the left operand rather than the guard expression: naming the + * function after its own guard. Returning the node unchanged degrades to the + * pre-guard behaviour instead, which is the safe direction. */ +static TSNode elixir_unwrap_one_guard(TSNode first_arg) { + if (!cbm_elixir_is_when_guard(first_arg)) { + return first_arg; + } + TSNode lhs = ts_node_child_by_field_name(first_arg, TS_FIELD("left")); + return ts_node_is_null(lhs) ? first_arg : lhs; +} + +/* The head under every guard a def's first argument carries, or first_arg + * unchanged when it carries none. Elixir admits more than one guard on a + * clause -- `def f(x) when is_atom(x) when is_binary(x)` -- and + * tree-sitter-elixir nests those RIGHT-associatively, measured on that exact + * source: `when(f(x), when(is_atom(x), is_binary(x)))`. The declared head is + * therefore the outer operator's left operand and one peel reaches it. The + * peel still runs to a fixed point: the extra iteration is one token compare, + * and it is what makes the helper's contract "the head under EVERY guard" + * rather than "the head under the first one", which is the property the three + * walks rely on. + * + * Left wrapped, the defs walk drops the clause, the unified walk opens no + * function scope (so every call in a guarded body sources to the FILE node), + * the calls walk stops recognising the head and emits it as an invocation of + * the very function being defined, and the usages walk swallows every + * identifier in the guard as part of the binding. Four walks read this same + * argument, which is why the unwrap lives here rather than in any one of + * them. */ +TSNode cbm_elixir_def_head_unwrap_guard(TSNode first_arg) { + for (;;) { + TSNode next = elixir_unwrap_one_guard(first_arg); + if (ts_node_eq(next, first_arg)) { + return first_arg; + } + first_arg = next; + } +} + +/* True when `node` is the head a def declares, or any of the `when` guard + * operators wrapping it. The calls walk needs the whole chain, not just its + * ends: each member reaches that walk by its own route -- `def f(x) when g` + * arrives as the inner `f(x)` call, `def f when g` has no inner call at all and + * the operator itself falls through to extract_callee_name's first-identifier + * last resort -- and a member it fails to recognise as a declaration is emitted + * as an invocation of the function being defined. */ +bool cbm_elixir_def_head_is(TSNode signature, TSNode node) { + for (;;) { + if (ts_node_eq(signature, node)) { + return true; + } + TSNode next = elixir_unwrap_one_guard(signature); + if (ts_node_eq(next, signature)) { + return false; + } + signature = next; + } +} + static const char *func_node_name(CBMArena *a, TSNode func_node, const char *source, CBMLanguage lang) { // Wolfram: set_delayed_top/set_top/set_delayed/set — LHS is apply(user_symbol("f"), ...) diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 905de04a8d..c18f27429b 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -103,6 +103,26 @@ const char *cbm_nix_binding_scope_qn(CBMExtractCtx *ctx, TSNode node, const char // attrpath and an enclosing attrset compose into one qualified name. const char *cbm_nix_qn_name(CBMArena *a, TSNode func_node, const char *source, const char *name); +// ── Elixir def-head guards ── +// `def f(x) when g` parses its WHOLE head as a `when` binary_operator, so the +// `f(x)` call naming the function is the operator's left operand, and more than +// one guard (`when a when b`) nests those operators right-associatively, so the +// head stays the outermost operator's left operand in either shape. Four +// walks read that same first argument — the defs walk names the function from +// it, the unified walk opens the function's call scope from it, the calls walk +// tells a head apart from an invocation by it, and the usages walk excludes the +// head's identifiers from the usage set by it — so a private copy in one of +// them makes the rest disagree about what a guarded clause is. +// cbm_elixir_is_when_guard answers "is this node a guarded head"; the unwrap +// returns the head under every guard, or the node unchanged when it carries +// none; cbm_elixir_def_head_is answers whether a node is that head or one of +// the guard operators above it, which is the question the calls walk asks. +// All three test the operator token, so an operator definition (`def a + b`) is +// left alone rather than mis-read as a guard. +bool cbm_elixir_is_when_guard(TSNode node); +TSNode cbm_elixir_def_head_unwrap_guard(TSNode first_arg); +bool cbm_elixir_def_head_is(TSNode signature, TSNode node); + // Resolve a function/method definition node's NAME node across all ~130 grammars // (generic `name` field, arrow→declarator, C/C++ declarator chain, plus the many // per-language quirks: Fortran subroutine, SCSS mixin, SQL create_function, R, diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 619b940452..8e8fd146ac 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -16,6 +16,8 @@ #include "result_spill.h" #include "pipeline/pass_lsp_cross.h" #include "iris_export_xml.h" +#include "helpers.h" +#include "lang_specs.h" /* ── Helpers ───────────────────────────────────────────────────── */ @@ -1151,6 +1153,167 @@ TEST(elixir_function) { PASS(); } +/* ── Elixir def-head guard helpers (internal/cbm/helpers.c) ───────── + * + * `def name(args) when guard` parses its WHOLE head as one `when` + * binary_operator whose left operand is the `name(args)` call, and Elixir + * admits more than one guard on a clause, which tree-sitter-elixir nests + * right-associatively. Four walks read that same first argument and each asks a + * different question of it: the defs and call-scope walks want the head UNDER + * every guard, the usages walk wants it so that a parameter READ by the guard + * stays outside the binding, and the calls walk wants to know whether the node + * it is visiting IS that head or one of the guard operators above it. + * + * That last question is why cbm_elixir_def_head_is exists beside the unwrap, + * and it is exercised here on the AST rather than through cbm_extract_file + * because the two routes it has to answer for are indistinguishable from + * outside: `def f(x) when g` reaches the calls walk as the inner `f(x)` call, + * which the unwrap reaches, while `def f when g` has no inner call at all and + * reaches it as the `when` operator itself, which the unwrap does not. A test + * that only drives one walk sees whichever of the two that walk happens to hit. + * + * This file's helpers change no extraction behaviour on their own; the walks + * that consume them are separate changes. */ + +enum { ELIXIR_HEAD_PROBE_MAX = 16, ELIXIR_HEAD_STACK_MAX = 256 }; + +static bool head_probe_text_is(const char *source, TSNode node, const char *want) { + if (ts_node_is_null(node)) { + return false; + } + uint32_t start = ts_node_start_byte(node); + uint32_t end = ts_node_end_byte(node); + size_t len = strlen(want); + return end >= start && (size_t)(end - start) == len && memcmp(source + start, want, len) == 0; +} + +/* The first argument of every `def`/`defp` call under `root`, in source order. + * That argument is what all four walks read, so it is what the helpers take. */ +typedef struct { + TSNode signature[ELIXIR_HEAD_PROBE_MAX]; + int count; +} elixir_head_probe_t; + +static void collect_elixir_def_signatures(TSNode root, const char *source, + elixir_head_probe_t *out) { + TSNode stack[ELIXIR_HEAD_STACK_MAX]; + int top = 0; + stack[top++] = root; + while (top > 0) { + TSNode cur = stack[--top]; + uint32_t cc = ts_node_child_count(cur); + if (cc > 1 && strcmp(ts_node_type(cur), "call") == 0 && + (head_probe_text_is(source, ts_node_child(cur, 0), "def") || + head_probe_text_is(source, ts_node_child(cur, 0), "defp")) && + out->count < ELIXIR_HEAD_PROBE_MAX) { + TSNode args = ts_node_child(cur, 1); + if (!ts_node_is_null(args) && ts_node_child_count(args) > 0) { + out->signature[out->count++] = ts_node_child(args, 0); + } + } + for (int i = (int)cc - 1; i >= 0 && top < ELIXIR_HEAD_STACK_MAX; i--) { + stack[top++] = ts_node_child(cur, (uint32_t)i); + } + } +} + +TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it) { + const char *source = "defmodule Heads do\n" + " def plain(x), do: x\n" + " def guarded(x) when is_binary(x), do: x\n" + " def bare when true, do: :ok\n" + " def twice(x) when is_atom(x) when is_binary(x), do: x\n" + " def a + b, do: {a, b}\n" + "end\n"; + const TSLanguage *language = cbm_ts_language(CBM_LANG_ELIXIR); + ASSERT_NOT_NULL(language); + TSParser *parser = ts_parser_new(); + ASSERT_NOT_NULL(parser); + ASSERT_TRUE(ts_parser_set_language(parser, language)); + TSTree *tree = ts_parser_parse_string(parser, NULL, source, (uint32_t)strlen(source)); + ASSERT_NOT_NULL(tree); + + elixir_head_probe_t probe; + probe.count = 0; + collect_elixir_def_signatures(ts_tree_root_node(tree), source, &probe); + ASSERT_EQ(5, probe.count); + + /* Unguarded: nothing to peel, and the head is the whole first argument. + * The name node inside it is NOT the head -- the calls walk compares the + * node it is visiting, and the `plain` identifier is one of those. */ + TSNode plain = probe.signature[0]; + ASSERT_FALSE(cbm_elixir_is_when_guard(plain)); + ASSERT_TRUE(ts_node_eq(cbm_elixir_def_head_unwrap_guard(plain), plain)); + ASSERT_TRUE(cbm_elixir_def_head_is(plain, plain)); + ASSERT_FALSE(cbm_elixir_def_head_is(plain, ts_node_child(plain, 0))); + + /* One guard: the first argument is the operator, the head is its left + * operand, and BOTH answer true -- the operator is the node the paren-less + * route hands the calls walk, the inner call is the node the peel route + * hands it. The guard expression answers false, or the walk would suppress + * a real call written inside a guard. */ + TSNode guarded = probe.signature[1]; + ASSERT_TRUE(cbm_elixir_is_when_guard(guarded)); + TSNode guarded_head = cbm_elixir_def_head_unwrap_guard(guarded); + ASSERT_FALSE(ts_node_eq(guarded_head, guarded)); + ASSERT_STR_EQ("call", ts_node_type(guarded_head)); + ASSERT_TRUE(head_probe_text_is(source, guarded_head, "guarded(x)")); + ASSERT_TRUE(cbm_elixir_def_head_is(guarded, guarded)); + ASSERT_TRUE(cbm_elixir_def_head_is(guarded, guarded_head)); + TSNode guard_expr = ts_node_child_by_field_name(guarded, TS_FIELD("right")); + ASSERT_FALSE(ts_node_is_null(guard_expr)); + ASSERT_TRUE(head_probe_text_is(source, guard_expr, "is_binary(x)")); + ASSERT_FALSE(cbm_elixir_def_head_is(guarded, guard_expr)); + + /* Paren-less: the head under the guard is a bare identifier, so there is no + * inner call for the peel route to produce. This is the shape that makes + * cbm_elixir_def_head_is necessary: the only node the calls walk ever sees + * for this clause is the operator. */ + TSNode bare = probe.signature[2]; + ASSERT_TRUE(cbm_elixir_is_when_guard(bare)); + TSNode bare_head = cbm_elixir_def_head_unwrap_guard(bare); + ASSERT_STR_EQ("identifier", ts_node_type(bare_head)); + ASSERT_TRUE(head_probe_text_is(source, bare_head, "bare")); + ASSERT_TRUE(cbm_elixir_def_head_is(bare, bare)); + ASSERT_TRUE(cbm_elixir_def_head_is(bare, bare_head)); + + /* Two guards on one clause. tree-sitter-elixir nests them + * RIGHT-associatively -- `when(twice(x), when(is_atom(x), is_binary(x)))`, + * which is what this asserts -- so the head is still the outer operator's + * left operand and the peel reaches it in one step. The repeated peel is + * therefore a fixed point rather than a chain walk on this grammar; the + * nodes that must answer true are the operator and the head. */ + TSNode twice = probe.signature[3]; + ASSERT_TRUE(cbm_elixir_is_when_guard(twice)); + TSNode second_guard = ts_node_child_by_field_name(twice, TS_FIELD("right")); + ASSERT_TRUE(cbm_elixir_is_when_guard(second_guard)); + TSNode twice_head = cbm_elixir_def_head_unwrap_guard(twice); + ASSERT_TRUE(head_probe_text_is(source, twice_head, "twice(x)")); + ASSERT_TRUE(cbm_elixir_def_head_is(twice, twice)); + ASSERT_TRUE(cbm_elixir_def_head_is(twice, twice_head)); + /* The second guard is a `when` operator too, and it is NOT on the head + * chain: a walk that treated any `when` above the head as a declaration + * would suppress the calls written inside it. */ + ASSERT_FALSE(cbm_elixir_def_head_is(twice, second_guard)); + + /* An operator definition's head is a binary_operator too, and unwrapping it + * would name the function after its own left parameter. Only the `when` + * token admits the peel, so `def a + b` is returned unchanged and its left + * operand is not a head. */ + TSNode op_def = probe.signature[4]; + ASSERT_STR_EQ("binary_operator", ts_node_type(op_def)); + ASSERT_FALSE(cbm_elixir_is_when_guard(op_def)); + ASSERT_TRUE(ts_node_eq(cbm_elixir_def_head_unwrap_guard(op_def), op_def)); + ASSERT_TRUE(cbm_elixir_def_head_is(op_def, op_def)); + TSNode op_lhs = ts_node_child_by_field_name(op_def, TS_FIELD("left")); + ASSERT_TRUE(head_probe_text_is(source, op_lhs, "a")); + ASSERT_FALSE(cbm_elixir_def_head_is(op_def, op_lhs)); + + ts_tree_delete(tree); + ts_parser_delete(parser); + PASS(); +} + /* tree-sitter-elixir gives a call's arguments node no field name, so the * generic `arguments` field lookup returns null and first_string_arg was never * populated for any Elixir call — Phoenix route paths, service URLs and config @@ -8289,6 +8452,7 @@ SUITE(extraction) { /* Functional */ RUN_TEST(elixir_function); + RUN_TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it); RUN_TEST(elixir_call_string_argument); RUN_TEST(haskell_function); RUN_TEST(ocaml_function); From 2a6dd92f658b15d571130b0eee14a67b95a36465 Mon Sep 17 00:00:00 2001 From: Chad <4307099+BobbieBarker@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:13:11 -0700 Subject: [PATCH 2/2] fix(elixir): extract a def head wrapped in a `when` guard extract_elixir_func_def reads the def's first argument directly and accepts only a `call` or an `identifier` there. A guard makes that argument a binary_operator, so the extractor found no name and returned. Every guarded clause was dropped, and a function whose clauses ALL carry guards never reached the graph at all -- silently, because a missing definition is not an error anywhere in the pipeline. Guards are ordinary Elixir, not a corner. Measured by indexing one 970-file Elixir lib tree with the binary built from e783f73d and with this one, and diffing the `nodes` and `edges` rows out of the two SQLite stores: Function nodes 20,882 -> 21,941 new qualified names 1,056 (functions with no node at all before) lost qualified names 0 CALLS edges 55,812 -> 57,090 USAGE edges 27,418 -> 28,223 Every one of those 1,056 is a function whose every clause carries a guard: a clause without one already minted the node they collided on. An agent asking "who calls this" about any of them got an empty answer that reads exactly like dead code. The unwrap is the shared helper, not a local peel, because the calls, unified and usages walks read the same node and a private copy in one of them makes the rest disagree about what a guarded clause is. TWO REGRESSIONS come with the new node, and both belong in this message rather than in a reviewer's lap. Neither is minted here: both are pre-existing phantoms that had nowhere to land while guarded clauses were dropped, and now have a node. 1. A phantom CALLS edge onto the function being defined. The calls walk decides a def head is a declaration by comparing the visited node against the def's first argument, and a guard makes that argument the `when` operator, so the inner `name(args)` call no longer compares equal. cbm.c flags self-recursion by line containment, so the phantom lands inside the function's own span: self_recursive Function nodes 129 -> 1,342 ...with no self-call form in span 24 -> 1,241 (18.6% -> 92.5%) (measured by reading each flagged node's own source span and looking for any non-declaration line that names it as a call, a capture or a pipe target.) 2. A phantom WRITES edge onto it, from a PAREN-LESS guarded head. `def f when g` has no inner call at all, so the `when` operator reaches the read/write extractor and its first identifier is recorded as an assignment target. Reproduced on `def bare_guarded when true, do: :ok`: one CBMReadWrite row, var_name "bare_guarded", is_write true, scoped to the file's Module node -- i.e. the graph asserting that a module mutates a function. Corpus WRITES 11,220 -> 11,302 (+82). Both are closed by the guarded call-scope commit later in this stack, and by different mechanisms. The CALLS phantom goes because that commit admits the `when` operator to the definition-container check, so a paren-less head is recognised as a declaration. The WRITES phantom goes because that commit opens a function scope for the clause: the read/write row is still recorded, but its enclosing_func_qn becomes the guarded clause itself rather than the enclosing module, so resolve_rw_edges finds src->id == tgt->id and drops the self-edge. Both are stated here because this commit is what makes them observable, and a stack that lands partially must not hide them. tests/test_extraction.c: extract_elixir_guarded_def_head covers every guarded shape -- one guard, two guards, a `defp`, a paren-less `def bare when true` -- and both operator-definition forms that must NOT be unwrapped (`def a + b`, `def c - d when is_integer(c)`, neither of which is extracted, as before). It fails on the current tree for each of the guarded names. Depends on #N (the shared def-head helper). Signed-off-by: Chad <4307099+BobbieBarker@users.noreply.github.com> Co-Authored-By: Claude Opus 5 (1M context) --- internal/cbm/extract_defs.c | 10 ++++++++ tests/test_extraction.c | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 93da084be1..26c43939a9 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -5268,6 +5268,16 @@ static void extract_elixir_func_def(CBMExtractCtx *ctx, TSNode node, const char return; } + // `def name(args) when guard` parses the whole head as a `when` + // binary_operator, so the name lives on its left operand rather than + // directly under the call. Without unwrapping it, every guarded clause is + // dropped, and a function whose clauses ALL carry guards never appears in + // the graph at all -- silently, since a missing definition is not an error. + // The unwrap lives in helpers.c because it peels only `when`: an operator + // definition (`def a + b`) is a binary_operator head too, and unwrapping + // that one would name the function after its own left parameter. + first_arg = cbm_elixir_def_head_unwrap_guard(first_arg); + const char *fk = ts_node_type(first_arg); char *name = NULL; if (strcmp(fk, "call") == 0 && ts_node_child_count(first_arg) > 0) { diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 8e8fd146ac..2db9833554 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -1314,6 +1314,54 @@ TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it) { PASS(); } +/* `def name(args) when guard` parses its whole head as a `when` + * binary_operator, so the name sits on the operator's left operand rather than + * directly under the call. The def extractor accepted only `call` and + * `identifier` there, so every guarded clause was dropped -- and a function + * whose clauses ALL carry guards never reached the graph at all, silently. + * Guards are ordinary Elixir: on one real 2.9k-function codebase this hid 216 + * of 270 all-guarded functions, and every call edge touching them. */ +TEST(extract_elixir_guarded_def_head) { + CBMFileResult *r = extract("defmodule Guarded do\n" + " def plain(x), do: x\n" + " def guarded(x) when is_binary(x), do: x\n" + " def guarded(x) when is_list(x), do: x\n" + " def multi(x, y) when is_binary(x) and is_integer(y), do: {x, y}\n" + " defp priv_guarded(x) when is_atom(x), do: x\n" + " def bare when true, do: :ok\n" + " def a + b, do: {a, b}\n" + " def c - d when is_integer(c), do: {c, d}\n" + " def multi_guard(x) when is_atom(x) when is_binary(x), do: x\n" + "end\n", + CBM_LANG_ELIXIR, "t", "guarded.ex"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT(has_def(r, "Function", "plain")); + ASSERT(has_def(r, "Function", "guarded")); + ASSERT(has_def(r, "Function", "multi")); + ASSERT(has_def(r, "Function", "priv_guarded")); + ASSERT(has_def(r, "Function", "bare")); + + /* Elixir admits more than one guard on a clause. tree-sitter-elixir nests + * those `when` operators right-associatively, so the declared head stays + * the outermost operator's left operand -- one node either way, and the + * peel must not stop short of it or return the guard. */ + ASSERT(has_def(r, "Function", "multi_guard")); + ASSERT_EQ(count_defs_named(r, "Function", "multi_guard"), 1); + + /* An operator definition is a binary_operator head too, but it is not a + * guard: unwrapping it names the function after its own LEFT PARAMETER. + * `def a + b` must not mint a function called `a`, nor `def c - d when ...` + * one called `c` -- the guard's left operand is itself the `c - d` + * operator, which names no function either. Neither operator form is + * extracted at all, which is what this grammar did before guards were + * handled; only the `when` token admits the unwrap. */ + ASSERT_EQ(count_defs_named(r, "Function", "a"), 0); + ASSERT_EQ(count_defs_named(r, "Function", "c"), 0); + cbm_free_result(r); + PASS(); +} + /* tree-sitter-elixir gives a call's arguments node no field name, so the * generic `arguments` field lookup returns null and first_string_arg was never * populated for any Elixir call — Phoenix route paths, service URLs and config @@ -8453,6 +8501,7 @@ SUITE(extraction) { /* Functional */ RUN_TEST(elixir_function); RUN_TEST(elixir_def_head_is_covers_the_head_and_every_guard_above_it); + RUN_TEST(extract_elixir_guarded_def_head); RUN_TEST(elixir_call_string_argument); RUN_TEST(haskell_function); RUN_TEST(ocaml_function);