Skip to content

Commit 3d5da60

Browse files
committed
Add Elixir language support (.ex/.exs)
Extracts defmodule, def/defp, alias/import/require/use, and call graph. Follows same custom-walk pattern as Zig and PowerShell extractors.
1 parent f6dd049 commit 3d5da60

6 files changed

Lines changed: 245 additions & 3 deletions

File tree

‎graphify/detect.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ class FileType(str, Enum):
1717

1818
_MANIFEST_PATH = "graphify-out/manifest.json"
1919

20-
CODE_EXTENSIONS = {'.py', '.ts', '.js', '.tsx', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc', '.zig', '.ps1'}
20+
CODE_EXTENSIONS = {'.py', '.ts', '.js', '.tsx', '.go', '.rs', '.java', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.toc', '.zig', '.ps1', '.ex', '.exs'}
2121
DOC_EXTENSIONS = {'.md', '.txt', '.rst'}
2222
PAPER_EXTENSIONS = {'.pdf'}
2323
IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'}

‎graphify/extract.py‎

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

19332110
def 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:

‎pyproject.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "graphifyy"
7-
version = "0.3.5"
7+
version = "0.3.6"
88
description = "AI coding assistant skill (Claude Code, Codex, OpenCode, OpenClaw) - turn any folder of code, docs, papers, or images into a queryable knowledge graph"
99
readme = "README.md"
1010
license = { file = "LICENSE" }
@@ -30,6 +30,7 @@ dependencies = [
3030
"tree-sitter-lua",
3131
"tree-sitter-zig",
3232
"tree-sitter-powershell",
33+
"tree-sitter-elixir",
3334
]
3435

3536
[project.urls]

‎tests/fixtures/sample.ex‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
defmodule MyApp.Accounts.User do
2+
@moduledoc """
3+
Handles user accounts and authentication.
4+
"""
5+
6+
alias MyApp.Repo
7+
import Ecto.Query
8+
9+
defstruct [:id, :name, :email]
10+
11+
def create(attrs) do
12+
%__MODULE__{}
13+
|> validate(attrs)
14+
|> Repo.insert()
15+
end
16+
17+
def find(id) do
18+
Repo.get(__MODULE__, id)
19+
end
20+
21+
defp validate(user, attrs) do
22+
if Map.has_key?(attrs, :email) do
23+
user
24+
else
25+
{:error, :missing_email}
26+
end
27+
end
28+
end

‎tests/test_extract.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ def test_collect_files_from_dir():
6161
supported = {".py", ".js", ".ts", ".tsx", ".go", ".rs",
6262
".java", ".c", ".cpp", ".cc", ".cxx", ".rb",
6363
".cs", ".kt", ".kts", ".scala", ".php", ".h", ".hpp",
64-
".swift", ".lua", ".toc", ".zig", ".ps1"}
64+
".swift", ".lua", ".toc", ".zig", ".ps1", ".ex", ".exs"}
6565
assert all(f.suffix in supported for f in files)
6666
assert len(files) > 0
6767

‎tests/test_languages.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,3 +337,37 @@ def test_swift_emits_calls():
337337
r = extract_swift(FIXTURES / "sample.swift")
338338
calls = _calls(r)
339339
assert any("process" in src and "validate" in tgt for src, tgt in calls)
340+
341+
342+
# ── Elixir ────────────────────────────────────────────────────────────────────
343+
344+
from graphify.extract import extract_elixir
345+
346+
def test_elixir_finds_module():
347+
r = extract_elixir(FIXTURES / "sample.ex")
348+
assert "error" not in r
349+
labels = [n["label"] for n in r["nodes"]]
350+
assert any("MyApp.Accounts.User" in l for l in labels)
351+
352+
def test_elixir_finds_functions():
353+
r = extract_elixir(FIXTURES / "sample.ex")
354+
labels = [n["label"] for n in r["nodes"]]
355+
assert any("create" in l for l in labels)
356+
assert any("find" in l for l in labels)
357+
assert any("validate" in l for l in labels)
358+
359+
def test_elixir_finds_imports():
360+
r = extract_elixir(FIXTURES / "sample.ex")
361+
import_edges = [e for e in r["edges"] if e["relation"] == "imports"]
362+
assert len(import_edges) >= 2
363+
364+
def test_elixir_finds_calls():
365+
r = extract_elixir(FIXTURES / "sample.ex")
366+
calls = {(e["source"], e["target"]) for e in r["edges"] if e["relation"] == "calls"}
367+
labels = {n["id"]: n["label"] for n in r["nodes"]}
368+
assert any("create" in labels.get(src, "") and "validate" in labels.get(tgt, "") for src, tgt in calls)
369+
370+
def test_elixir_method_edges():
371+
r = extract_elixir(FIXTURES / "sample.ex")
372+
methods = [e for e in r["edges"] if e["relation"] == "method"]
373+
assert len(methods) >= 3

0 commit comments

Comments
 (0)