diff --git a/graphify/extractors/elixir.py b/graphify/extractors/elixir.py index 85bf867c0d..e622d0f9e2 100644 --- a/graphify/extractors/elixir.py +++ b/graphify/extractors/elixir.py @@ -120,6 +120,30 @@ def _get_defimpl_target(node) -> str | None: return val return None + def _get_do_keyword_body(node): + """The body of a keyword-form definition (`def f(x), do: expr`). + + tree-sitter-elixir puts the keyword body in the `arguments` node's + trailing `keywords` child, as the value of the `pair` whose keyword + is `do:` (#4207). Returns the value node, or None when the definition + uses a `do_block` (or has no keyword body at all). + """ + if node is None: + return None + for child in node.children: + if child.type != "keywords": + continue + for pair in child.children: + if pair.type != "pair": + continue + keyword_text = None + for sub in pair.children: + if sub.type == "keyword": + keyword_text = source[sub.start_byte:sub.end_byte].decode("utf-8", errors="replace") + elif keyword_text is not None and keyword_text.rstrip(": ").strip() == "do": + return sub + return None + def walk(node, parent_module_nid: str | None = None) -> None: if node.type != "call": for child in node.children: @@ -251,6 +275,14 @@ def walk(node, parent_module_nid: str | None = None) -> None: add_edge(file_nid, func_nid, "contains", line) if do_block_node: function_bodies.append((func_nid, do_block_node)) + else: + # Keyword form (`def f(x), do: expr`) has no `do_block`; its + # body is the value of the `do:` pair in `arguments` (#4207). + # Without this the body is never walked and its calls are + # silently dropped from the graph. + keyword_body = _get_do_keyword_body(arguments_node) + if keyword_body is not None: + function_bodies.append((func_nid, keyword_body)) return if keyword in _IMPORT_KEYWORDS and arguments_node: diff --git a/tests/test_elixir_keyword_def_calls.py b/tests/test_elixir_keyword_def_calls.py new file mode 100644 index 0000000000..d1647bf7ba --- /dev/null +++ b/tests/test_elixir_keyword_def_calls.py @@ -0,0 +1,70 @@ +"""Keyword-form Elixir definitions keep their calls edges (#4207). + +A one-line definition (`def a(x), do: b(x)`) has no `do_block` node: the +body is the value of the `do:` pair in the `arguments` keywords. The +extractor only registered `do_block` bodies for the call-walk pass, so every +call inside a keyword-form body was silently dropped from the graph. +""" + +from __future__ import annotations + +from pathlib import Path + +from graphify.extract import extract + +_CORPUS = { + "lib/short.ex": ( + "defmodule App.Short do\n" + " def a(x), do: b(x)\n" + "\n" + " def b(x) do\n" + " x\n" + " end\n" + "\n" + " defp c(x), do: d(x) |> e(x)\n" + "\n" + " def g(x) when is_list(x), do: b(x)\n" + "end\n" + ), +} + + +def _calls(tmp_path: Path) -> set[tuple[str, str]]: + paths = [] + for name, body in _CORPUS.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + paths.append(path) + result = extract(paths, cache_root=tmp_path / "graphify-out") + label = {n["id"]: n["label"] for n in result["nodes"]} + return { + (label[e["source"]], label[e["target"]]) + for e in result["edges"] + if e["relation"] == "calls" and e["source"] in label and e["target"] in label + } + + +def test_keyword_def_body_calls_are_extracted(tmp_path: Path): + """`def a(x), do: b(x)` must emit a calls edge a() -> b().""" + calls = _calls(tmp_path) + assert ("a()", "b()") in calls + + +def test_guarded_keyword_def_body_calls_are_extracted(tmp_path: Path): + """A `when`-guarded keyword head keeps its keyword body walkable.""" + calls = _calls(tmp_path) + assert ("g()", "b()") in calls + + +def test_keyword_def_without_local_targets_emits_no_dangling_edges(tmp_path: Path): + """`defp c(x), do: d(x) |> e(x)` references undefined functions: the walk + must not mint calls edges to non-existent nodes.""" + calls = _calls(tmp_path) + assert not any(src == "c()" for src, _ in calls) + + +def test_block_def_behavior_is_unchanged(tmp_path: Path): + """The block form still registers its function node exactly once.""" + calls = _calls(tmp_path) + assert ("b()", "b()") not in calls # no self-edge invented