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.
This commit is contained in:
rajatnagda45 committed 2026-09-25 23:51:55 +05:30
1 parent 4000de1546
commit 5f4e979693
2 files changed
+119

No files matched your search

+59
View File
@@ -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:
+60
View File
@@ -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