fix(elixir): expand multi-alias brace form into per-module imports edges

`alias Foo.{Bar, Baz}` (and the same import/require/use brace form) emitted
NO imports edges. tree-sitter-elixir represents it as a `dot` node holding the
base alias plus a trailing `tuple` of member aliases, but the import handler
only matched a bare `alias` child, so every multi-alias import was silently
dropped.

Add `_get_alias_modules`, which expands the brace form to `Foo.Bar`,
`Foo.Baz`, … while leaving the single form (`alias Foo.Bar`) unchanged. Adds a
fixture line + regression test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Synvoya
2026-07-01 20:41:59 +10:00
committed by safishamsi
parent 8127ff9a9c
commit f2ea6a6087
3 changed files with 48 additions and 2 deletions
+30 -2
View File
@@ -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
+1
View File
@@ -4,6 +4,7 @@ defmodule MyApp.Accounts.User do
"""
alias MyApp.Repo
alias MyApp.Schemas.{Account, Token}
import Ecto.Query
defstruct [:id, :name, :email]
+17
View File
@@ -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"}