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