Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions internal/cbm/extract_defs.c
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
87 changes: 87 additions & 0 deletions internal/cbm/helpers.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"), ...)
Expand Down
20 changes: 20 additions & 0 deletions internal/cbm/helpers.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
213 changes: 213 additions & 0 deletions tests/test_extraction.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────── */

Expand Down Expand Up @@ -1151,6 +1153,215 @@ 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();
}

/* `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
Expand Down Expand Up @@ -8289,6 +8500,8 @@ 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);
Expand Down
Loading