From 1ebbcdbfb7f522cd873217001c33ee7183cce21d Mon Sep 17 00:00:00 2001 From: rajatnagda45 Date: Fri, 25 Sep 2026 23:51:55 +0530 Subject: [PATCH] fix(elixir): extract defprotocol and defimpl as containers defprotocol and defimpl were not recognized as definition keywords, so the walker fell through to generic recursion with no parent module. The protocol and implementation nodes were never created, and their functions were attached to the file node instead of their container (two orphaned size() nodes hanging off the file rather than off the protocol and its List implementation). Handle both like module-like containers: mint the protocol/impl node, contain it in its enclosing scope, and walk the do-block so each def becomes a method of the protocol or implementation. The implementation also gets an implements edge to its protocol; a cross-file protocol target is filtered by the existing dangling-edge guard rather than left hanging. (cherry picked from commit 5f4e979693a703876e6cd23939d23c992774db08) --- graphify/extractors/elixir.py | 59 ++++++++++++++++++++++++++++++++++ tests/test_languages.py | 60 +++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) diff --git a/graphify/extractors/elixir.py b/graphify/extractors/elixir.py index fb0d5dc46..5d2745db2 100644 --- a/graphify/extractors/elixir.py +++ b/graphify/extractors/elixir.py @@ -89,6 +89,27 @@ def extract_elixir(path: Path) -> dict: return [_text(child)] return [] + def _get_defimpl_target(node) -> str | None: + """The `for:` target module of a `defimpl Proto, for: Type` argument + list. The grammar holds it in a trailing `keywords` node as a `pair` + whose keyword is `for:` and whose value is an `alias`.""" + for child in node.children: + if child.type != "keywords": + continue + for pair in child.children: + if pair.type != "pair": + continue + kw = None + val = None + for sub in pair.children: + if sub.type == "keyword": + kw = source[sub.start_byte:sub.end_byte].decode("utf-8", errors="replace") + elif sub.type == "alias": + val = source[sub.start_byte:sub.end_byte].decode("utf-8", errors="replace") + if kw and kw.rstrip(": ").strip() == "for" and val: + return val + return None + def walk(node, parent_module_nid: str | None = None) -> None: if node.type != "call": for child in node.children: @@ -133,6 +154,44 @@ def extract_elixir(path: Path) -> dict: walk(child, parent_module_nid=module_nid) return + if keyword == "defprotocol": + # A protocol is a module-like named container. Without this branch + # the `defprotocol` call fell through to the generic recursion with + # parent_module_nid=None, so the protocol node was never minted and + # its callbacks (`def size(data)`) were attached to the FILE instead + # of the protocol. + proto_name = _get_alias_text(arguments_node) if arguments_node else None + if not proto_name: + return + proto_nid = _make_id(stem, proto_name) + add_node(proto_nid, proto_name, line, + **({"_elixir_module": True} if parent_module_nid is None else {})) + add_edge(parent_module_nid or file_nid, proto_nid, "contains", line) + if do_block_node: + for child in do_block_node.children: + walk(child, parent_module_nid=proto_nid) + return + + if keyword == "defimpl": + # `defimpl Proto, for: Type do ... end`. Same orphaning bug as + # defprotocol: the implementation's functions leaked onto the file. + proto_name = _get_alias_text(arguments_node) if arguments_node else None + if not proto_name: + return + target = _get_defimpl_target(arguments_node) + impl_nid = _make_id(stem, "defimpl", proto_name, target or "") + label = f"{proto_name} (for {target})" if target else proto_name + add_node(impl_nid, label, line) + add_edge(parent_module_nid or file_nid, impl_nid, "contains", line) + # Link the implementation to the protocol it satisfies. A same-file + # protocol resolves directly; a cross-file target is filtered out by + # the dangling-edge guard below rather than left hanging. + add_edge(impl_nid, _make_id(stem, proto_name), "implements", line) + if do_block_node: + for child in do_block_node.children: + walk(child, parent_module_nid=impl_nid) + return + if keyword in ("def", "defp"): func_name = None if arguments_node: diff --git a/tests/test_languages.py b/tests/test_languages.py index 4508bbfdd..62017376b 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -1705,6 +1705,66 @@ def test_elixir_guarded_single_clause_is_extracted(tmp_path): ) +def test_elixir_protocol_and_impl_are_extracted(tmp_path): + """`defprotocol`/`defimpl` are module-like containers. Before they were + handled, the protocol and implementation nodes were never minted and their + functions leaked onto the FILE node instead of their container.""" + src = tmp_path / "sizeable.ex" + src.write_text( + "defprotocol Sizeable do\n" + " def size(data)\n" + "end\n" + "\n" + "defimpl Sizeable, for: List do\n" + " def size(data), do: length(data)\n" + "end\n" + ) + r = extract_elixir(src) + assert "error" not in r + + file_nid = next(n["id"] for n in r["nodes"] if n["label"] == "sizeable.ex") + labels = {n["label"] for n in r["nodes"]} + assert "Sizeable" in labels + assert "Sizeable (for List)" in labels + + lab = {n["id"]: n["label"] for n in r["nodes"]} + method_pairs = { + (lab.get(e["source"]), lab.get(e["target"])) + for e in r["edges"] if e["relation"] == "method" + } + # the callback and the implementation function belong to their containers + assert ("Sizeable", "size()") in method_pairs + assert ("Sizeable (for List)", "size()") in method_pairs + # ...and NOT to the file (the pre-fix symptom) + assert not [ + e for e in r["edges"] + if e["source"] == file_nid and lab.get(e["target"]) == "size()" + ] + # the implementation is linked to the protocol it satisfies + impl_pairs = { + (lab.get(e["source"]), lab.get(e["target"])) + for e in r["edges"] if e["relation"] == "implements" + } + assert ("Sizeable (for List)", "Sizeable") in impl_pairs + + +def test_elixir_protocol_impl_produce_no_dangling_edges(tmp_path): + """An implementation of a protocol defined in ANOTHER file must not leave a + dangling `implements` edge behind.""" + src = tmp_path / "impl_only.ex" + src.write_text( + "defimpl Sizeable, for: Map do\n" + " def size(data), do: map_size(data)\n" + "end\n" + ) + r = extract_elixir(src) + ids = {n["id"] for n in r["nodes"]} + for e in r["edges"]: + if e["relation"] == "imports": + continue + assert e["source"] in ids and e["target"] in ids, e + + # ── Objective-C ────────────────────────────────────────────────────────────── from graphify.extract import extract_objc