diff --git a/graphify/extractors/elixir.py b/graphify/extractors/elixir.py index 6bb425e0..0f1588dd 100644 --- a/graphify/extractors/elixir.py +++ b/graphify/extractors/elixir.py @@ -58,6 +58,35 @@ def extract_elixir(path: Path) -> dict: return source[child.start_byte:child.end_byte].decode("utf-8", errors="replace") return None + def _get_alias_modules(node) -> list[str]: + """Every module named by an alias/import/require/use argument. + + Handles the single form (``alias Foo.Bar`` -> ``["Foo.Bar"]``) and the + multi-alias brace form (``alias Foo.{Bar, Baz}`` -> + ``["Foo.Bar", "Foo.Baz"]``), which the grammar represents as a ``dot`` + node holding the base alias and a trailing ``tuple`` of member aliases. + """ + def _text(n) -> str: + return source[n.start_byte:n.end_byte].decode("utf-8", errors="replace") + + for child in node.children: + if child.type == "alias": + return [_text(child)] + if child.type == "dot": + base = None + tuple_node = None + for sub in child.children: + if sub.type == "alias" and base is None: + base = _text(sub) + elif sub.type == "tuple": + tuple_node = sub + if base and tuple_node is not None: + members = [_text(m) for m in tuple_node.children if m.type == "alias"] + if members: + return [f"{base}.{m}" for m in members] + return [_text(child)] + return [] + def walk(node, parent_module_nid: str | None = None) -> None: if node.type != "call": for child in node.children: @@ -121,8 +150,7 @@ def extract_elixir(path: Path) -> dict: return if keyword in _IMPORT_KEYWORDS and arguments_node: - module_name = _get_alias_text(arguments_node) - if module_name: + for module_name in _get_alias_modules(arguments_node): tgt_nid = _make_id(module_name) add_edge(file_nid, tgt_nid, "imports", line, context="import") return diff --git a/tests/fixtures/sample.ex b/tests/fixtures/sample.ex index 9e048216..fb85745f 100644 --- a/tests/fixtures/sample.ex +++ b/tests/fixtures/sample.ex @@ -4,6 +4,7 @@ defmodule MyApp.Accounts.User do """ alias MyApp.Repo + alias MyApp.Schemas.{Account, Token} import Ecto.Query defstruct [:id, :name, :email] diff --git a/tests/test_languages.py b/tests/test_languages.py index b41c105b..0976b489 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -975,6 +975,23 @@ def test_elixir_import_edges_have_import_context(): assert import_edges assert all(e.get("context") == "import" for e in import_edges) + +def test_elixir_multi_alias_expands(): + """`alias Foo.{Bar, Baz}` must emit one imports edge per expanded module. + + The brace form is a `dot` node with a trailing `tuple`; the single-alias + handler only matched a bare `alias` child, so every multi-alias import was + silently dropped. + """ + r = extract_elixir(FIXTURES / "sample.ex") + import_segs = [ + e["target"].rsplit("_", 1)[-1] + for e in r["edges"] if e["relation"] == "imports" + ] + # from `alias MyApp.Schemas.{Account, Token}` + assert "account" in import_segs, "MyApp.Schemas.Account import missing" + assert "token" in import_segs, "MyApp.Schemas.Token import missing" + def test_elixir_finds_calls(): r = extract_elixir(FIXTURES / "sample.ex") calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"}