@@ -1928,6 +1928,183 @@ def walk_imports(node) -> None:
19281928 return new_edges
19291929
19301930
1931+ def extract_elixir (path : Path ) -> dict :
1932+ """Extract modules, functions, imports, and calls from a .ex/.exs file."""
1933+ try :
1934+ import tree_sitter_elixir as tselixir
1935+ from tree_sitter import Language , Parser
1936+ except ImportError :
1937+ return {"nodes" : [], "edges" : [], "error" : "tree_sitter_elixir not installed" }
1938+
1939+ try :
1940+ language = Language (tselixir .language ())
1941+ parser = Parser (language )
1942+ source = path .read_bytes ()
1943+ tree = parser .parse (source )
1944+ root = tree .root_node
1945+ except Exception as e :
1946+ return {"nodes" : [], "edges" : [], "error" : str (e )}
1947+
1948+ stem = path .stem
1949+ str_path = str (path )
1950+ nodes : list [dict ] = []
1951+ edges : list [dict ] = []
1952+ seen_ids : set [str ] = set ()
1953+ function_bodies : list [tuple [str , Any ]] = []
1954+
1955+ def add_node (nid : str , label : str , line : int ) -> None :
1956+ if nid not in seen_ids :
1957+ seen_ids .add (nid )
1958+ nodes .append ({"id" : nid , "label" : label , "file_type" : "code" ,
1959+ "source_file" : str_path , "source_location" : f"L{ line } " })
1960+
1961+ def add_edge (src : str , tgt : str , relation : str , line : int ,
1962+ confidence : str = "EXTRACTED" , weight : float = 1.0 ) -> None :
1963+ edges .append ({"source" : src , "target" : tgt , "relation" : relation ,
1964+ "confidence" : confidence , "source_file" : str_path ,
1965+ "source_location" : f"L{ line } " , "weight" : weight })
1966+
1967+ file_nid = _make_id (stem )
1968+ add_node (file_nid , path .name , 1 )
1969+
1970+ _IMPORT_KEYWORDS = frozenset ({"alias" , "import" , "require" , "use" })
1971+
1972+ def _get_alias_text (node ) -> str | None :
1973+ for child in node .children :
1974+ if child .type == "alias" :
1975+ return source [child .start_byte :child .end_byte ].decode ("utf-8" , errors = "replace" )
1976+ return None
1977+
1978+ def walk (node , parent_module_nid : str | None = None ) -> None :
1979+ if node .type != "call" :
1980+ for child in node .children :
1981+ walk (child , parent_module_nid )
1982+ return
1983+
1984+ identifier_node = None
1985+ arguments_node = None
1986+ do_block_node = None
1987+ for child in node .children :
1988+ if child .type == "identifier" :
1989+ identifier_node = child
1990+ elif child .type == "arguments" :
1991+ arguments_node = child
1992+ elif child .type == "do_block" :
1993+ do_block_node = child
1994+
1995+ if identifier_node is None :
1996+ for child in node .children :
1997+ walk (child , parent_module_nid )
1998+ return
1999+
2000+ keyword = source [identifier_node .start_byte :identifier_node .end_byte ].decode ("utf-8" , errors = "replace" )
2001+ line = node .start_point [0 ] + 1
2002+
2003+ if keyword == "defmodule" :
2004+ module_name = _get_alias_text (arguments_node ) if arguments_node else None
2005+ if not module_name :
2006+ return
2007+ module_nid = _make_id (stem , module_name )
2008+ add_node (module_nid , module_name , line )
2009+ add_edge (file_nid , module_nid , "contains" , line )
2010+ if do_block_node :
2011+ for child in do_block_node .children :
2012+ walk (child , parent_module_nid = module_nid )
2013+ return
2014+
2015+ if keyword in ("def" , "defp" ):
2016+ func_name = None
2017+ if arguments_node :
2018+ for child in arguments_node .children :
2019+ if child .type == "call" :
2020+ for sub in child .children :
2021+ if sub .type == "identifier" :
2022+ func_name = source [sub .start_byte :sub .end_byte ].decode ("utf-8" , errors = "replace" )
2023+ break
2024+ elif child .type == "identifier" :
2025+ func_name = source [child .start_byte :child .end_byte ].decode ("utf-8" , errors = "replace" )
2026+ break
2027+ if not func_name :
2028+ return
2029+ container = parent_module_nid or file_nid
2030+ func_nid = _make_id (container , func_name )
2031+ add_node (func_nid , f"{ func_name } ()" , line )
2032+ if parent_module_nid :
2033+ add_edge (parent_module_nid , func_nid , "method" , line )
2034+ else :
2035+ add_edge (file_nid , func_nid , "contains" , line )
2036+ if do_block_node :
2037+ function_bodies .append ((func_nid , do_block_node ))
2038+ return
2039+
2040+ if keyword in _IMPORT_KEYWORDS and arguments_node :
2041+ module_name = _get_alias_text (arguments_node )
2042+ if module_name :
2043+ tgt_nid = _make_id (module_name )
2044+ add_edge (file_nid , tgt_nid , "imports" , line )
2045+ return
2046+
2047+ for child in node .children :
2048+ walk (child , parent_module_nid )
2049+
2050+ walk (root )
2051+
2052+ label_to_nid : dict [str , str ] = {}
2053+ for n in nodes :
2054+ normalised = n ["label" ].strip ("()" ).lstrip ("." )
2055+ label_to_nid [normalised .lower ()] = n ["id" ]
2056+
2057+ seen_call_pairs : set [tuple [str , str ]] = set ()
2058+ _SKIP_KEYWORDS = frozenset ({
2059+ "def" , "defp" , "defmodule" , "defmacro" , "defmacrop" ,
2060+ "defstruct" , "defprotocol" , "defimpl" , "defguard" ,
2061+ "alias" , "import" , "require" , "use" ,
2062+ "if" , "unless" , "case" , "cond" , "with" , "for" ,
2063+ })
2064+
2065+ def walk_calls (node , caller_nid : str ) -> None :
2066+ if node .type != "call" :
2067+ for child in node .children :
2068+ walk_calls (child , caller_nid )
2069+ return
2070+ for child in node .children :
2071+ if child .type == "identifier" :
2072+ kw = source [child .start_byte :child .end_byte ].decode ("utf-8" , errors = "replace" )
2073+ if kw in _SKIP_KEYWORDS :
2074+ for c in node .children :
2075+ walk_calls (c , caller_nid )
2076+ return
2077+ break
2078+ callee_name : str | None = None
2079+ for child in node .children :
2080+ if child .type == "dot" :
2081+ dot_text = source [child .start_byte :child .end_byte ].decode ("utf-8" , errors = "replace" )
2082+ parts = dot_text .rstrip ("." ).split ("." )
2083+ if parts :
2084+ callee_name = parts [- 1 ]
2085+ break
2086+ if child .type == "identifier" :
2087+ callee_name = source [child .start_byte :child .end_byte ].decode ("utf-8" , errors = "replace" )
2088+ break
2089+ if callee_name :
2090+ tgt_nid = label_to_nid .get (callee_name .lower ())
2091+ if tgt_nid and tgt_nid != caller_nid :
2092+ pair = (caller_nid , tgt_nid )
2093+ if pair not in seen_call_pairs :
2094+ seen_call_pairs .add (pair )
2095+ add_edge (caller_nid , tgt_nid , "calls" ,
2096+ node .start_point [0 ] + 1 , confidence = "INFERRED" , weight = 0.8 )
2097+ for child in node .children :
2098+ walk_calls (child , caller_nid )
2099+
2100+ for caller_nid , body in function_bodies :
2101+ walk_calls (body , caller_nid )
2102+
2103+ clean_edges = [e for e in edges if e ["source" ] in seen_ids and
2104+ (e ["target" ] in seen_ids or e ["relation" ] == "imports" )]
2105+ return {"nodes" : nodes , "edges" : clean_edges , "input_tokens" : 0 , "output_tokens" : 0 }
2106+
2107+
19312108# ── Main extract and collect_files ────────────────────────────────────────────
19322109
19332110def extract (paths : list [Path ]) -> dict :
@@ -1980,6 +2157,8 @@ def extract(paths: list[Path]) -> dict:
19802157 ".toc" : extract_lua ,
19812158 ".zig" : extract_zig ,
19822159 ".ps1" : extract_powershell ,
2160+ ".ex" : extract_elixir ,
2161+ ".exs" : extract_elixir ,
19832162 }
19842163
19852164 for path in paths :
0 commit comments