Current section

Files

Jump to
ragex lib ragex analyzers meta_ast_extractor.ex
Raw

lib/ragex/analyzers/meta_ast_extractor.ex

defmodule Ragex.Analyzers.MetaASTExtractor do
@moduledoc """
Language-agnostic entity extraction from Metastatic MetaAST.
Walks a `Metastatic.Document`'s AST to extract modules, functions,
calls, and imports into the `Ragex.Analyzers.Behaviour.analysis_result()`
shape. This replaces the native language-specific analyzers for entity
extraction, providing a single code path that works identically for
every language Metastatic supports.
## Extracted Entities
- **Modules** -- `:container` nodes with `container_type: :module` or `:class`
- **Functions** -- `:function_def` nodes with name, arity, visibility
- **Calls** -- `:function_call` nodes with caller/callee resolution
- **Imports** -- `:import` nodes with source and import type
## Usage
alias Ragex.Analyzers.MetaASTExtractor
alias Metastatic.Document
{:ok, doc} = Ragex.LanguageSupport.parse_file("lib/my_module.ex")
{:ok, result} = MetaASTExtractor.extract(doc, "lib/my_module.ex")
result.modules # => [%{name: "MyModule", file: "lib/my_module.ex", line: 1, ...}]
result.functions # => [%{name: :my_func, arity: 2, module: "MyModule", ...}]
result.calls # => [%{from_module: "MyModule", to_function: :other, ...}]
result.imports # => [%{from_module: "MyModule", to_module: "OtherModule", ...}]
"""
alias Metastatic.Document
@type context :: %{
file: String.t(),
language: atom(),
container: term(),
function: atom() | nil,
arity: non_neg_integer() | nil
}
@type acc :: %{
modules: [map()],
functions: [map()],
calls: [map()],
imports: [map()]
}
@doc """
Extracts entities from a `Metastatic.Document`.
Returns `{:ok, analysis_result}` with modules, functions, calls, and imports
in the shape expected by `Ragex.Analyzers.Behaviour`.
## Parameters
- `doc` -- a `Metastatic.Document` (from `Ragex.LanguageSupport.parse_file/2`)
- `file_path` -- path to the source file (used in entity metadata)
## Examples
{:ok, doc} = Ragex.LanguageSupport.parse_file("lib/my_module.ex")
{:ok, result} = MetaASTExtractor.extract(doc, "lib/my_module.ex")
"""
@spec extract(Document.t(), String.t()) :: {:ok, map()} | {:error, term()}
def extract(%Document{ast: ast, language: language}, file_path) when is_binary(file_path) do
ctx = %{
file: file_path,
language: language || :unknown,
container: nil,
function: nil,
arity: nil
}
acc = %{modules: [], functions: [], calls: [], imports: []}
{_ast, result} = walk(ast, ctx, acc)
{:ok,
%{
modules: Enum.reverse(result.modules),
functions: Enum.reverse(result.functions),
calls: Enum.reverse(result.calls),
imports: Enum.reverse(result.imports)
}}
rescue
e -> {:error, {:extraction_failed, Exception.message(e)}}
end
@doc """
Convenience wrapper: parses a file and extracts entities in one step.
## Examples
{:ok, result} = MetaASTExtractor.extract_file("lib/my_module.ex")
"""
@spec extract_file(String.t(), keyword()) :: {:ok, map()} | {:error, term()}
def extract_file(path, opts \\ []) do
with {:ok, doc} <- Ragex.LanguageSupport.parse_file(path, opts) do
extract(doc, path)
end
end
# Private functions
# Walk the AST recursively, collecting entities.
# We do manual recursion instead of AST.traverse/4 because we need
# to update the context (current container / function) on the way down
# and restore it on the way up.
defp walk({:container, meta, body}, ctx, acc) when is_list(meta) and is_list(body) do
raw_name = Keyword.get(meta, :name, "unknown")
name = normalize_module_name(raw_name, ctx.language)
line = Keyword.get(meta, :line, 0)
container_type = Keyword.get(meta, :container_type, :module)
mod_entry = %{
name: name,
file: ctx.file,
line: line,
doc: nil,
metadata: %{container_type: container_type}
}
acc = %{acc | modules: [mod_entry | acc.modules]}
inner_ctx = %{ctx | container: name}
# Walk children with updated context
{body, acc} = walk_list(body, inner_ctx, acc)
{{:container, meta, body}, acc}
end
defp walk({:function_def, meta, body}, ctx, acc) when is_list(meta) and is_list(body) do
name_str = Keyword.get(meta, :name, "unknown")
params = Keyword.get(meta, :params, [])
visibility = Keyword.get(meta, :visibility, :public)
line = Keyword.get(meta, :line, 0)
arity = length(params)
func_entry = %{
name: String.to_atom(name_str),
arity: arity,
module: ctx.container || "top_level",
file: ctx.file,
line: line,
doc: nil,
visibility: visibility,
metadata: %{params: extract_param_names(params)}
}
acc = %{acc | functions: [func_entry | acc.functions]}
inner_ctx = %{
ctx
| function: String.to_atom(name_str),
arity: arity
}
# Walk body with function context
{body, acc} = walk_list(body, inner_ctx, acc)
{{:function_def, meta, body}, acc}
end
defp walk({:function_call, meta, args}, ctx, acc) when is_list(meta) and is_list(args) do
name_str = Keyword.get(meta, :name, "unknown")
line = Keyword.get(meta, :line, 0)
call_arity = length(args)
{to_module, to_function} = split_call_name(name_str, ctx.language)
call_entry = %{
from_module: ctx.container || normalize_module_name("top_level", ctx.language),
from_function: ctx.function || :top_level,
from_arity: ctx.arity || 0,
to_module: to_module,
to_function: to_function,
to_arity: call_arity,
line: line
}
acc = %{acc | calls: [call_entry | acc.calls]}
# Walk arguments for nested calls
{args, acc} = walk_list(args, ctx, acc)
{{:function_call, meta, args}, acc}
end
defp walk({:import, meta, children}, ctx, acc) when is_list(meta) do
source = Keyword.get(meta, :source, "unknown")
import_type = Keyword.get(meta, :import_type, :import)
import_entry = %{
from_module: ctx.container || normalize_module_name("top_level", ctx.language),
to_module: normalize_module_name(source, ctx.language),
type: import_type
}
acc = %{acc | imports: [import_entry | acc.imports]}
{{:import, meta, children}, acc}
end
# Child spec nodes: extract supervisor child metadata
defp walk({:child_spec, meta, body}, ctx, acc) when is_list(meta) do
mod = Keyword.get(meta, :module, "unknown")
id = Keyword.get(meta, :id, "unknown")
kind = Keyword.get(meta, :kind, :worker)
line = Keyword.get(meta, :line, 0)
call_entry = %{
from_module: ctx.container || "top_level",
from_function: :child_spec,
from_arity: 0,
to_module: mod,
to_function: :start_link,
to_arity: 1,
line: line,
metadata: %{child_id: id, child_kind: kind}
}
acc = %{acc | calls: [call_entry | acc.calls]}
{{:child_spec, meta, body}, acc}
end
# Generic 3-tuple node: recurse into list children
defp walk({type, meta, children}, ctx, acc)
when is_atom(type) and is_list(meta) and is_list(children) do
{children, acc} = walk_list(children, ctx, acc)
{{type, meta, children}, acc}
end
# 3-tuple with non-list children (leaf-like): pass through
defp walk({type, meta, value}, _ctx, acc) when is_atom(type) and is_list(meta) do
{{type, meta, value}, acc}
end
# Bare list (top-level or nested statements)
defp walk(list, ctx, acc) when is_list(list) do
walk_list(list, ctx, acc)
end
# Anything else (literals, nil, etc.)
defp walk(other, _ctx, acc), do: {other, acc}
defp walk_list(list, ctx, acc) do
Enum.map_reduce(list, acc, fn node, acc -> walk(node, ctx, acc) end)
end
# Split "Module.func" into {module, :func}.
# Handles dotted names like "Enum.map", "MyApp.Repo.get", and bare "func".
# Module part is normalized according to language conventions.
defp split_call_name(name, language) when is_binary(name) do
case String.split(name, ".") do
[single] ->
{nil, String.to_atom(single)}
parts ->
func = List.last(parts)
mod = parts |> Enum.drop(-1) |> Enum.join(".")
{normalize_module_name(mod, language), String.to_atom(func)}
end
end
# Normalize module names according to language conventions.
# Elixir: "TestModule" -> Module atom (Elixir.TestModule)
# Erlang: "my_module" -> :my_module atom
# Others: kept as strings
defp normalize_module_name(name, :elixir) when is_binary(name) do
name
|> String.split(".")
|> Enum.map(&String.to_atom/1)
|> Module.concat()
end
defp normalize_module_name(name, :erlang) when is_binary(name) do
String.to_atom(name)
end
defp normalize_module_name(name, _language), do: name
defp extract_param_names(params) do
Enum.map(params, fn
{:param, _meta, name} when is_binary(name) -> name
_ -> "_"
end)
end
end