11"""Elixir extractor. Moved verbatim from graphify/extract.py."""
22from __future__ import annotations
33
4+ import re
45from pathlib import Path
56from typing import Any
67
@@ -61,6 +62,11 @@ def add_edge(src: str, tgt: str, relation: str, line: int,
6162 # is the file's top level), along with each module's and function's parent.
6263 call_scope : dict [str | None , list [str ]] = {}
6364 enclosing : dict [str , str | None ] = {}
65+ # For qualified calls (`Mod.fun()`, #4206): each module's full name, each
66+ # scope's `alias` table (same scoping as call_scope) and each module's defs.
67+ module_names : dict [str , str ] = {}
68+ aliases : dict [str | None , dict [str , str ]] = {}
69+ module_defs : dict [tuple [str , str ], str ] = {}
6470
6571 _IMPORT_KEYWORDS = frozenset ({"alias" , "import" , "require" , "use" })
6672
@@ -99,10 +105,11 @@ def _text(n) -> str:
99105 return [_text (child )]
100106 return []
101107
102- def _get_defimpl_target (node ) -> str | None :
103- """The `for:` target module of a `defimpl Proto, for: Type` argument
104- list. The grammar holds it in a trailing `keywords` node as a `pair`
105- whose keyword is `for:` and whose value is an `alias`."""
108+ def _get_keyword_alias (node , key : str ) -> str | None :
109+ """The module given for keyword ``key`` in an argument list, e.g. the
110+ `for:` of `defimpl Proto, for: Type` or the `as:` of `alias A.B, as: C`.
111+ The grammar holds it in a trailing `keywords` node as a `pair` whose
112+ keyword is `key:` and whose value is an `alias`."""
106113 for child in node .children :
107114 if child .type != "keywords" :
108115 continue
@@ -116,10 +123,53 @@ def _get_defimpl_target(node) -> str | None:
116123 kw = source [sub .start_byte :sub .end_byte ].decode ("utf-8" , errors = "replace" )
117124 elif sub .type == "alias" :
118125 val = source [sub .start_byte :sub .end_byte ].decode ("utf-8" , errors = "replace" )
119- if kw and kw .rstrip (": " ).strip () == "for" and val :
126+ if kw and kw .rstrip (": " ).strip () == key and val :
120127 return val
121128 return None
122129
130+ def _expand_module (name : str , scope : str | None ) -> str :
131+ """Expand the first segment of a module reference through the `alias`
132+ tables visible from ``scope`` (or `__MODULE__` to the current module)."""
133+ first , _ , rest = name .partition ("." )
134+ target = None
135+ if first == "__MODULE__" :
136+ # ``scope`` is a module, or a function directly inside one.
137+ if scope is not None and scope not in module_names :
138+ scope = enclosing .get (scope )
139+ target = (module_names .get (scope ) if scope is not None else None ) or None
140+ else :
141+ seen : set [str | None ] = set ()
142+ container = scope
143+ while container not in seen :
144+ seen .add (container )
145+ target = aliases .get (container , {}).get (first )
146+ if target is not None or container is None :
147+ break
148+ container = enclosing .get (container )
149+ if target is None :
150+ return name
151+ return f"{ target } .{ rest } " if rest else target
152+
153+ def _record_aliases (arguments_node , scope : str | None ) -> None :
154+ modules = [_expand_module (m , scope ) for m in _get_alias_modules (arguments_node )]
155+ as_name = _get_keyword_alias (arguments_node , "as" )
156+ table = aliases .setdefault (scope , {})
157+ if as_name and len (modules ) == 1 :
158+ table [as_name ] = modules [0 ]
159+ elif not as_name :
160+ for module in modules :
161+ table [module .rsplit ("." , 1 )[- 1 ]] = module
162+
163+ def _register_module (nid : str , name : str , parent : str | None ) -> None :
164+ # A nested `defmodule Inner` is `Outer.Inner`, and `Inner` is
165+ # auto-aliased inside Outer.
166+ parent_name = module_names .get (parent ) if parent is not None else None
167+ if parent_name :
168+ first = name .split ("." , 1 )[0 ]
169+ aliases .setdefault (parent , {})[first ] = f"{ parent_name } .{ first } "
170+ name = f"{ parent_name } .{ name } "
171+ module_names [nid ] = name
172+
123173 def walk (node , parent_module_nid : str | None = None ) -> None :
124174 if node .type != "call" :
125175 for child in node .children :
@@ -160,6 +210,7 @@ def walk(node, parent_module_nid: str | None = None) -> None:
160210 ** ({"_elixir_module" : True } if parent_module_nid is None else {}))
161211 add_edge (file_nid , module_nid , "contains" , line )
162212 enclosing [module_nid ] = parent_module_nid
213+ _register_module (module_nid , module_name , parent_module_nid )
163214 if do_block_node :
164215 for child in do_block_node .children :
165216 walk (child , parent_module_nid = module_nid )
@@ -179,6 +230,7 @@ def walk(node, parent_module_nid: str | None = None) -> None:
179230 ** ({"_elixir_module" : True } if parent_module_nid is None else {}))
180231 add_edge (parent_module_nid or file_nid , proto_nid , "contains" , line )
181232 enclosing [proto_nid ] = parent_module_nid
233+ _register_module (proto_nid , proto_name , parent_module_nid )
182234 if do_block_node :
183235 for child in do_block_node .children :
184236 walk (child , parent_module_nid = proto_nid )
@@ -190,12 +242,13 @@ def walk(node, parent_module_nid: str | None = None) -> None:
190242 proto_name = _get_alias_text (arguments_node ) if arguments_node else None
191243 if not proto_name :
192244 return
193- target = _get_defimpl_target (arguments_node )
245+ target = _get_keyword_alias (arguments_node , "for" )
194246 impl_nid = _make_id (stem , "defimpl" , proto_name , target or "" )
195247 label = f"{ proto_name } (for { target } )" if target else proto_name
196248 add_node (impl_nid , label , line )
197249 add_edge (parent_module_nid or file_nid , impl_nid , "contains" , line )
198250 enclosing [impl_nid ] = parent_module_nid
251+ module_names [impl_nid ] = f"{ proto_name } .{ target } " if target else ""
199252 # Link the implementation to the protocol it satisfies. A same-file
200253 # protocol resolves directly; a cross-file target is filtered out by
201254 # the dangling-edge guard below rather than left hanging.
@@ -247,6 +300,7 @@ def walk(node, parent_module_nid: str | None = None) -> None:
247300 enclosing [func_nid ] = parent_module_nid
248301 if parent_module_nid :
249302 add_edge (parent_module_nid , func_nid , "method" , line )
303+ module_defs .setdefault ((parent_module_nid , func_name ), func_nid )
250304 else :
251305 add_edge (file_nid , func_nid , "contains" , line )
252306 if do_block_node :
@@ -261,6 +315,8 @@ def walk(node, parent_module_nid: str | None = None) -> None:
261315 # calls; alias/require do not.
262316 if keyword in ("import" , "use" ):
263317 call_scope .setdefault (parent_module_nid , []).append (module_name )
318+ if keyword == "alias" :
319+ _record_aliases (arguments_node , parent_module_nid )
264320 return
265321
266322 for child in node .children :
@@ -273,6 +329,9 @@ def walk(node, parent_module_nid: str | None = None) -> None:
273329 normalised = n ["label" ].strip ("()" ).lstrip ("." )
274330 label_to_nid [normalised ] = n ["id" ]
275331
332+ module_nid_by_name = {name : nid for nid , name in module_names .items ()}
333+ _MODULE_REF = re .compile (r"(__MODULE__|[A-Z]\w*)(\.[A-Z]\w*)*" )
334+
276335 seen_call_pairs : set [tuple [str , str ]] = set ()
277336 raw_calls : list [dict ] = []
278337 _SKIP_KEYWORDS = frozenset ({
@@ -304,25 +363,44 @@ def walk_calls(node, caller_nid: str) -> None:
304363 if child .type == "identifier" :
305364 kw = source [child .start_byte :child .end_byte ].decode ("utf-8" , errors = "replace" )
306365 if kw in _SKIP_KEYWORDS :
366+ if kw == "alias" : # a function-local alias
367+ for c in node .children :
368+ if c .type == "arguments" :
369+ _record_aliases (c , caller_nid )
307370 for c in node .children :
308371 walk_calls (c , caller_nid )
309372 return
310373 break
311374 callee_name : str | None = None
312375 is_member_call : bool = False
376+ receiver_module : str | None = None
313377 for child in node .children :
314378 if child .type == "dot" :
315379 is_member_call = True
316380 dot_text = source [child .start_byte :child .end_byte ].decode ("utf-8" , errors = "replace" )
317381 parts = dot_text .rstrip ("." ).split ("." )
318382 if parts :
319383 callee_name = parts [- 1 ]
384+ # `Mod.fun()` / `Alias.fun()` / `__MODULE__.fun()`: keep the
385+ # module. A variable (`mod.fun()`) or atom receiver stays None.
386+ if child .child_count == 3 :
387+ receiver = child .children [0 ]
388+ receiver_text = source [receiver .start_byte :receiver .end_byte ].decode (
389+ "utf-8" , errors = "replace" )
390+ if _MODULE_REF .fullmatch (receiver_text ):
391+ receiver_module = _expand_module (receiver_text , caller_nid )
320392 break
321393 if child .type == "identifier" :
322394 callee_name = source [child .start_byte :child .end_byte ].decode ("utf-8" , errors = "replace" )
323395 break
324- if callee_name and callee_name not in _LANGUAGE_BUILTIN_GLOBALS :
325- tgt_nid = label_to_nid .get (callee_name )
396+ if callee_name and (receiver_module or callee_name not in _LANGUAGE_BUILTIN_GLOBALS ):
397+ # A remote call only ever reaches the named module's own def: never
398+ # the bare name, which bound `Repo.insert(x)` to a local insert/1.
399+ if receiver_module is not None :
400+ mod_nid = module_nid_by_name .get (receiver_module )
401+ tgt_nid = module_defs .get ((mod_nid , callee_name )) if mod_nid else None
402+ else :
403+ tgt_nid = None if is_member_call else label_to_nid .get (callee_name )
326404 if tgt_nid and tgt_nid != caller_nid :
327405 pair = (caller_nid , tgt_nid )
328406 if pair not in seen_call_pairs :
@@ -338,6 +416,7 @@ def walk_calls(node, caller_nid: str) -> None:
338416 "source_file" : str_path ,
339417 "source_location" : f"L{ node .start_point [0 ] + 1 } " ,
340418 "elixir_call_scope" : _call_scope_for (caller_nid ),
419+ ** ({"elixir_module" : receiver_module } if receiver_module else {}),
341420 })
342421 for child in node .children :
343422 walk_calls (child , caller_nid )
0 commit comments