Current section

Files

Jump to
metastatic lib metastatic adapters elixir to_meta.ex
Raw

lib/metastatic/adapters/elixir/to_meta.ex

defmodule Metastatic.Adapters.Elixir.ToMeta do
@moduledoc """
Transform Elixir AST (M1) to MetaAST (M2).
This module implements the abstraction function α_Elixir that lifts
Elixir-specific AST structures to the meta-level representation.
## New 3-Tuple Format
All MetaAST nodes are uniform 3-element tuples:
{type_atom, keyword_meta, children_or_value}
Where:
- `type_atom` - Node type (e.g., `:literal`, `:binary_op`, `:function_def`)
- `keyword_meta` - Keyword list with metadata (line, subtype, operator, etc.)
- `children_or_value` - Value for leaf nodes, list of children for composites
## Metadata Preservation
The transformation preserves M1-specific information:
- `:original_meta` - Original Elixir AST metadata keyword list
- `:original_code` - Source code snippet (when available)
- `:line`, `:col` - Source location from Elixir metadata
## Transformation Strategy
Uses `Macro.traverse/4` for bottom-up AST transformation:
1. Pre-pass: Track context (module, function, arity)
2. Post-pass: Transform Elixir nodes to MetaAST nodes
## Examples
iex> {:ok, ast} = Code.string_to_quoted("x + 5")
iex> {:ok, meta_ast, _metadata} = ToMeta.transform(ast)
iex> meta_ast
{:binary_op, [category: :arithmetic, operator: :+, original_meta: [line: 1]],
[{:variable, [scope: :local, original_meta: [line: 1]], "x"},
{:literal, [subtype: :integer], 5}]}
"""
require Logger
# Arithmetic operators
@arithmetic_ops [:+, :-, :*, :/, :rem, :div]
# Comparison operators
@comparison_ops [:==, :!=, :<, :>, :<=, :>=, :===, :!==]
# Boolean operators
@boolean_ops [:and, :or, :&&, :||]
# All MetaAST node types recognized during transformation.
# Referenced in: post_transform pass-through, 2-tuple guard, meta_ast_node?/1
@meta_ast_types [
:literal,
:variable,
:binary_op,
:unary_op,
:function_call,
:conditional,
:block,
:list,
:map,
:pair,
:tuple,
:inline_match,
:assignment,
:container,
:function_def,
:lambda,
:pattern_match,
:match_arm,
:early_return,
:attribute_access,
:language_specific,
:collection_op,
:loop,
:exception_handling,
:async_operation,
:property,
:augmented_assignment,
:import,
:range,
:string_interpolation,
:comprehension,
:generator,
:filter,
:type_annotation
]
@map {:__aliases__, [alias: false], [:Map]}
@doc """
Transform Elixir AST to MetaAST.
Returns `{:ok, meta_ast, metadata}` on success or `{:error, reason}` on failure.
## Examples
iex> ToMeta.transform(42)
{:ok, {:literal, [subtype: :integer], 42}, %{}}
iex> ToMeta.transform({:x, [line: 1], nil})
{:ok, {:variable, [scope: :local, line: 1, original_meta: [line: 1]], "x"}, %{}}
"""
@spec transform(term()) :: {:ok, term(), map()} | {:error, String.t()}
def transform(elixir_ast) do
# Initial context for tracking module/function/arity
initial_context = %{
module: nil,
function: nil,
arity: nil,
visibility: :public
}
# Use Macro.traverse for bottom-up transformation
{meta_ast, final_context} =
Macro.traverse(
elixir_ast,
initial_context,
&pre_transform/2,
&post_transform/2
)
{:ok, meta_ast, final_context}
rescue
e -> {:error, "Transform failed: #{Exception.message(e)}"}
end
# ----- Pre-Transform: Context Tracking Only -----
# We only use pre-transform to track module/function context.
# The actual transformation happens in post_transform.
# Track entering a module
defp pre_transform({:defmodule, _meta, [{:__aliases__, _, parts} | _]} = ast, ctx)
when is_list(parts) do
module_name = safe_join_parts(parts)
{ast, %{ctx | module: module_name}}
end
# Track entering a protocol definition
defp pre_transform({:defprotocol, _meta, [{:__aliases__, _, parts} | _]} = ast, ctx)
when is_list(parts) do
protocol_name = safe_join_parts(parts)
{ast, %{ctx | module: protocol_name}}
end
# Track entering a protocol implementation
defp pre_transform(
{:defimpl, _meta, [{:__aliases__, _, parts} | _]} = ast,
ctx
)
when is_list(parts) do
protocol_name = safe_join_parts(parts)
{ast, %{ctx | module: protocol_name}}
end
# Track entering a guarded function
defp pre_transform({func_type, _meta, [{:when, _, [{name, _, args} | _]} | _]} = ast, ctx)
when func_type in [:def, :defp, :defmacro, :defmacrop] and is_atom(name) do
arity = if is_list(args), do: length(args), else: 0
visibility = if func_type in [:defp, :defmacrop], do: :private, else: :public
{ast, %{ctx | function: Atom.to_string(name), arity: arity, visibility: visibility}}
end
# Track entering a function
defp pre_transform({func_type, _meta, [{name, _, args} | _]} = ast, ctx)
when func_type in [:def, :defp, :defmacro, :defmacrop] and is_atom(name) do
arity = if is_list(args), do: length(args), else: 0
visibility = if func_type in [:defp, :defmacrop], do: :private, else: :public
{ast, %{ctx | function: Atom.to_string(name), arity: arity, visibility: visibility}}
end
# Map update syntactic sugar → Map.merge/2
defp pre_transform({:%{}, meta, [{:|, inner_meta, [name, values]}]}, ctx) do
ast =
{{:., meta, [@map, :merge]}, inner_meta,
[name, {{:., inner_meta, [@map, :new]}, inner_meta, [values]}]}
{ast, ctx}
end
# {{:., [], [{:foo, [], Elixir}, :bar]}, [no_parens: true], []}
# {{:., [], [{:__aliases__, [alias: false], [:Map]}, :fetch!]}, [], [{:foo, [], Elixir}, :bar]}
defp pre_transform({{:., dot_meta, [{_map, _, _}, _key] = args}, meta, []}, ctx) do
safe_meta = if Keyword.keyword?(meta), do: Keyword.delete(meta, :no_parens), else: meta
ast = {{:., dot_meta, [@map, :fetch!]}, safe_meta, args}
{ast, ctx}
end
# Handle function captures in pre_transform to prevent Macro.traverse from
# descending into the body and transforming it before we can process &1, &2, etc.
defp pre_transform({:&, _meta, [_body]} = ast, ctx) do
# Instead of transforming here, we mark captures to skip normal traversal.
# We'll return a marker tuple that tells post_transform to handle the original AST.
# Using nil as context marks it as a variable (leaf node) so Macro.traverse won't descend.
{{:__capture_marker__, [], nil}, Map.put(ctx, :pending_capture, ast)}
end
# Handle try/rescue/catch in pre_transform to preserve clause structure
# before children get transformed. We store the original and return a marker.
defp pre_transform({:try, _meta, [[{:do, _} | _] = _clauses]} = ast, ctx) do
{{:__try_marker__, [], nil}, Map.put(ctx, :pending_try, ast)}
end
# Handle with expressions in pre_transform to preserve clause structure
# (especially `<-` operators) before children get transformed.
defp pre_transform({:with, _meta, args} = ast, ctx) when is_list(args) do
{{:__with_marker__, [], nil}, Map.put(ctx, :pending_with, ast)}
end
# Handle for comprehensions in pre_transform to preserve `<-` generators
# before children get transformed.
defp pre_transform({:for, _meta, args} = ast, ctx) when is_list(args) do
{{:__for_marker__, [], nil}, Map.put(ctx, :pending_for, ast)}
end
# Handle quote blocks in pre_transform to prevent Macro.traverse from
# descending into template AST that contains unquote placeholders,
# non-standard __aliases__ forms, and other meta-programming constructs
# that are not real code.
defp pre_transform({:quote, _meta, args} = ast, ctx) when is_list(args) do
{{:__quote_marker__, [], nil}, Map.put(ctx, :pending_quote, ast)}
end
# Handle unquote -- when encountered outside a quote marker (e.g. in
# already-traversed code), preserve the original expression.
defp pre_transform({:unquote, _meta, args} = ast, ctx) when is_list(args) do
{{:__unquote_marker__, [], nil}, Map.put(ctx, :pending_unquote, ast)}
end
# Handle string interpolation in pre_transform to prevent Macro.traverse from
# descending into the binary parts and corrupting the interpolation structure.
defp pre_transform({:<<>>, _meta, parts} = ast, ctx) when is_list(parts) do
if has_interpolation?(parts) do
{{:__interpolation_marker__, [], nil}, Map.put(ctx, :pending_interpolation, ast)}
else
{ast, ctx}
end
end
# Handle module attributes in pre_transform to prevent Macro.traverse from
# descending into the inner {attr_name, meta, [value]} and transforming attr_name
# (e.g. :moduledoc) into a :function_call node before the @ handler sees it.
defp pre_transform({:@, _meta, [{attr_name, _, _}]} = ast, ctx)
when is_atom(attr_name) do
{{:__attr_marker__, [], nil}, Map.put(ctx, :pending_attr, ast)}
end
# Module attribute with non-atom name (e.g. inside quote blocks in kernel.ex)
defp pre_transform({:@, _meta, [_non_atom_attr]} = ast, ctx) do
{{:__attr_marker__, [], nil}, Map.put(ctx, :pending_attr, ast)}
end
# Default: pass through
defp pre_transform(ast, ctx), do: {ast, ctx}
# ----- Function Capture (called from pre_transform) -----
defp transform_capture(body, meta) do
case body do
# &1, &2, etc - argument reference
n when is_integer(n) ->
param_name = "arg_#{n}"
node_meta =
[params: [{:param, [], param_name}], capture_form: :argument_reference] ++
build_meta(meta)
body_ast = {:variable, [], param_name}
{:lambda, node_meta, [body_ast]}
# &Module.function/arity
{:/, _, [func_ref, arity]} when is_integer(arity) ->
func_name = extract_captured_function_name(func_ref)
params = for i <- 1..arity//1, do: {:param, [], "arg_#{i}"}
args = for i <- 1..arity//1, do: {:variable, [], "arg_#{i}"}
body_ast = {:function_call, [name: func_name], args}
node_meta = [params: params, capture_form: :named_function] ++ build_meta(meta)
{:lambda, node_meta, [body_ast]}
# &(&1 + &2) - expression capture
_ ->
case transform_capture_body_recursive(body) do
{transformed_body, 0} ->
# No capture arguments - treat as zero-arity lambda
node_meta = [params: [], capture_form: :no_arguments] ++ build_meta(meta)
{:lambda, node_meta, [transformed_body]}
{transformed_body, arg_count} ->
params = for i <- 1..arg_count//1, do: {:param, [], "arg_#{i}"}
node_meta =
[params: params, capture_form: :expression, arity: arg_count] ++ build_meta(meta)
{:lambda, node_meta, [transformed_body]}
end
end
end
# Transform capture body recursively without using Macro.traverse
defp transform_capture_body_recursive(body) do
do_transform_capture_body(body, 0)
end
defp do_transform_capture_body({:&, _, [n]}, max_arg) when is_integer(n) do
# Replace &1, &2, etc. with variable references
{{:variable, [], "arg_#{n}"}, max(max_arg, n)}
end
defp do_transform_capture_body({{:., dot_meta, [{:&, _, [n]}, key]}, _meta, []}, max_arg)
when is_integer(n) do
{{:function_call,
[
name: "Map.fetch!",
original_meta: dot_meta,
line: safe_keyword_get(dot_meta, :line, 1)
], [{:variable, [], "arg_#{n}"}, {:literal, [subtype: :symbol], key}]}, max(max_arg, n)}
end
defp do_transform_capture_body({op, meta, args}, max_arg) when is_atom(op) and is_list(args) do
# Transform children first
{transformed_args, new_max} =
Enum.map_reduce(args, max_arg, fn arg, acc ->
{t_arg, new_acc} = do_transform_capture_body(arg, acc)
{t_arg, new_acc}
end)
# Now transform this node
case classify_op(op) do
{:arithmetic, operator} ->
{{:binary_op, [category: :arithmetic, operator: operator] ++ build_meta(meta),
transformed_args}, new_max}
{:comparison, operator} ->
{{:binary_op, [category: :comparison, operator: operator] ++ build_meta(meta),
transformed_args}, new_max}
{:boolean, operator} ->
{{:binary_op, [category: :boolean, operator: operator] ++ build_meta(meta),
transformed_args}, new_max}
{:unary_boolean, operator} ->
{{:binary_op, [category: :boolean, operator: operator] ++ build_meta(meta),
transformed_args}, new_max}
:function_call ->
# It's a function call
func_name = Atom.to_string(op)
{{:function_call, [name: func_name] ++ build_meta(meta), transformed_args}, new_max}
end
end
# Remote function call: Module.func(args) in capture
defp do_transform_capture_body({{:., dot_meta, [module, func]}, call_meta, args}, max_arg) do
{transformed_args, new_max} =
Enum.map_reduce(args, max_arg, fn arg, acc ->
{t_arg, new_acc} = do_transform_capture_body(arg, acc)
{t_arg, new_acc}
end)
module_name = extract_module_name(module)
func_name = "#{module_name}.#{func}"
node_meta = [name: func_name] ++ build_meta(call_meta) ++ build_meta(dot_meta)
{{:function_call, node_meta, transformed_args}, new_max}
end
# Two-element tuple in capture: {&1, &2}
defp do_transform_capture_body({left, right}, max_arg) do
{t_left, max1} = do_transform_capture_body(left, max_arg)
{t_right, max2} = do_transform_capture_body(right, max1)
{{:tuple, [], [t_left, t_right]}, max2}
end
# List in capture: [&1, &2, &3]
defp do_transform_capture_body(list, max_arg) when is_list(list) do
{transformed, new_max} =
Enum.map_reduce(list, max_arg, fn elem, acc ->
{t_elem, new_acc} = do_transform_capture_body(elem, acc)
{t_elem, new_acc}
end)
{{:list, [], transformed}, new_max}
end
# Literals
defp do_transform_capture_body(value, max_arg) when is_integer(value) do
{{:literal, [subtype: :integer], value}, max_arg}
end
defp do_transform_capture_body(value, max_arg) when is_float(value) do
{{:literal, [subtype: :float], value}, max_arg}
end
defp do_transform_capture_body(value, max_arg) when is_binary(value) do
{{:literal, [subtype: :string], value}, max_arg}
end
defp do_transform_capture_body(true, max_arg) do
{{:literal, [subtype: :boolean], true}, max_arg}
end
defp do_transform_capture_body(false, max_arg) do
{{:literal, [subtype: :boolean], false}, max_arg}
end
defp do_transform_capture_body(nil, max_arg) do
{{:literal, [subtype: :null], nil}, max_arg}
end
defp do_transform_capture_body(atom, max_arg) when is_atom(atom) do
{{:literal, [subtype: :symbol], atom}, max_arg}
end
# Fallback - keep as is
defp do_transform_capture_body(other, max_arg) do
{other, max_arg}
end
defp classify_op(op) when op in @arithmetic_ops, do: {:arithmetic, op}
defp classify_op(op) when op in @comparison_ops, do: {:comparison, op}
defp classify_op(op) when op in @boolean_ops, do: {:boolean, normalize_bool_op(op)}
defp classify_op(:not), do: {:unary_boolean, :not}
defp classify_op(op) when is_atom(op), do: :function_call
defp normalize_bool_op(:&&), do: :and
defp normalize_bool_op(:||), do: :or
defp normalize_bool_op(op), do: op
# ----- Try/Rescue transformation (called from pre_transform marker) -----
# Transform a try/rescue/catch block before children are transformed
defp transform_try(clauses, meta) when is_list(clauses) do
try_block = Keyword.get(clauses, :do)
rescue_clauses = Keyword.get(clauses, :rescue, [])
catch_clauses = Keyword.get(clauses, :catch, [])
after_block = Keyword.get(clauses, :after)
# Transform the try block body
{:ok, try_body, _} = transform(try_block)
# Transform rescue clauses
handlers =
Enum.map(rescue_clauses ++ catch_clauses, fn
{:->, clause_meta, [[pattern], body]} ->
{:ok, transformed_body, _} = transform(body)
{:ok, transformed_pattern, _} = transform(pattern)
node_meta = [pattern: transformed_pattern] ++ build_meta(clause_meta)
{:match_arm, node_meta, flatten_body_ast(transformed_body)}
other ->
{:ok, transformed, _} = transform(other)
{:match_arm, [], [transformed]}
end)
# Transform after block if present
children = [try_body | handlers]
children =
if after_block do
{:ok, transformed_after, _} = transform(after_block)
children ++ [transformed_after]
else
children
end
node_meta = build_meta(meta)
{:exception_handling, node_meta, children}
end
# ----- Post-Transform: Node Conversion -----
# Handle __capture_marker__ - retrieve original capture from context and transform it
defp post_transform({:__capture_marker__, [], nil}, ctx) do
case Map.pop(ctx, :pending_capture) do
{{:&, meta, [body]}, new_ctx} ->
result = transform_capture(body, meta)
{result, new_ctx}
{nil, ctx} ->
# Should not happen, but handle gracefully
{{:literal, [subtype: :null], nil}, ctx}
end
end
# Handle __try_marker__ - retrieve original try/rescue from context and transform it
defp post_transform({:__try_marker__, [], nil}, ctx) do
case Map.pop(ctx, :pending_try) do
{{:try, meta, [clauses]}, new_ctx} ->
result = transform_try(clauses, meta)
{result, new_ctx}
{nil, ctx} ->
# Should not happen, but handle gracefully
{{:literal, [subtype: :null], nil}, ctx}
end
end
# Handle __with_marker__ - retrieve original with expression and transform it
defp post_transform({:__with_marker__, [], nil}, ctx) do
case Map.pop(ctx, :pending_with) do
{{:with, meta, args}, new_ctx} ->
result = transform_with(args, meta)
{result, new_ctx}
{nil, ctx} ->
{{:literal, [subtype: :null], nil}, ctx}
end
end
# Handle __for_marker__ - retrieve original for comprehension and transform it
defp post_transform({:__for_marker__, [], nil}, ctx) do
case Map.pop(ctx, :pending_for) do
{{:for, meta, args}, new_ctx} ->
result = transform_for(args, meta)
{result, new_ctx}
{nil, ctx} ->
{{:literal, [subtype: :null], nil}, ctx}
end
end
# Handle __interpolation_marker__ - produce :string_interpolation MetaAST node
defp post_transform({:__interpolation_marker__, [], nil}, ctx) do
case Map.pop(ctx, :pending_interpolation) do
{{:<<>>, meta, parts}, new_ctx} ->
# Transform interpolation parts individually
transformed_parts =
Enum.map(parts, fn
part when is_binary(part) ->
{:literal, [subtype: :string], part}
{:"::", _, [{{:., _, [Kernel, :to_string]}, _, [expr]}, {:binary, _, _}]} ->
{:ok, expr_ast, _} = transform(expr)
expr_ast
other ->
{:ok, ast, _} = transform(other)
ast
end)
node_meta = build_meta(meta)
{{:string_interpolation, node_meta, transformed_parts}, new_ctx}
{nil, ctx} ->
{{:literal, [subtype: :null], nil}, ctx}
end
end
# Handle __quote_marker__ - preserve quote body as language_specific node
defp post_transform({:__quote_marker__, [], nil}, ctx) do
case Map.pop(ctx, :pending_quote) do
{{:quote, meta, args}, new_ctx} ->
node_meta = [language: :elixir, hint: :quote] ++ build_meta(meta)
{{:language_specific, node_meta, {:quote, meta, args}}, new_ctx}
{nil, ctx} ->
{{:literal, [subtype: :null], nil}, ctx}
end
end
# Handle __unquote_marker__ - preserve unquote as language_specific node
defp post_transform({:__unquote_marker__, [], nil}, ctx) do
case Map.pop(ctx, :pending_unquote) do
{{:unquote, meta, [expr]}, new_ctx} ->
{:ok, expr_ast, _} = transform(expr)
node_meta = [language: :elixir, hint: :unquote] ++ build_meta(meta)
{{:language_specific, node_meta, [expr_ast]}, new_ctx}
{nil, ctx} ->
{{:literal, [subtype: :null], nil}, ctx}
end
end
# Handle __attr_marker__ - retrieve original module attribute from context and transform it
defp post_transform({:__attr_marker__, [], nil}, ctx) do
case Map.pop(ctx, :pending_attr) do
# Type annotation attributes: @spec, @type, @typep, @callback, @macrocallback
# The args list may contain constraint keywords, e.g.
# @type(name :: def, key: constraint) → [type_def, [key: constraint]]
{{:@, meta, [{attr_name, _attr_meta, [value | _constraints]}]}, new_ctx}
when attr_name in [:spec, :type, :typep, :callback, :macrocallback] ->
{func_name, arity} = extract_type_annotation_info(attr_name, value)
type_expr = {:language_specific, [language: :elixir, hint: :type_expr], value}
node_meta =
[annotation_type: attr_name, name: func_name, arity: arity] ++ build_meta(meta)
{{:type_annotation, node_meta, [type_expr]}, new_ctx}
# Setter: @attr value
{{:@, meta, [{attr_name, _attr_meta, [value]}]}, new_ctx} ->
{:ok, value_ast, _} = transform(value)
var_name = "@#{attr_name}"
node_meta = [attribute_type: :module_attribute] ++ build_meta(meta)
target = {:variable, [scope: :module_attribute], var_name}
{{:assignment, node_meta, [target, value_ast]}, new_ctx}
# Getter: @attr (no value)
{{:@, meta, [{attr_name, _attr_meta, nil}]}, new_ctx} ->
var_name = "@#{attr_name}"
node_meta =
[scope: :module_attribute, attribute_type: :module_attribute] ++ build_meta(meta)
{{:variable, node_meta, var_name}, new_ctx}
# Non-atom attribute name (e.g. {:{}, ...} inside quote blocks)
{{:@, meta, [_non_atom_attr]} = original, new_ctx} ->
node_meta = [language: :elixir, hint: :module_attribute] ++ build_meta(meta)
{{:language_specific, node_meta, original}, new_ctx}
{nil, ctx} ->
{{:literal, [subtype: :null], nil}, ctx}
end
end
# ----- Import Directives (import/use/require/alias) -----
# Must come BEFORE the general MetaAST pass-through since :import
# is both an Elixir AST atom and a MetaAST type.
defp post_transform({directive, meta, [module | _rest]}, ctx)
when directive in [:import, :use, :require, :alias] do
module_name = extract_module_name(module)
node_meta =
[source: module_name, import_type: directive, language: :elixir] ++ build_meta(meta)
{{:import, node_meta, []}, ctx}
end
# Already transformed nodes - pass through
defp post_transform({type, meta, _children} = ast, ctx)
when is_atom(type) and is_list(meta) and type in @meta_ast_types do
{ast, ctx}
end
# ----- Literals -----
# Integer
defp post_transform(value, ctx) when is_integer(value) do
{{:literal, [subtype: :integer], value}, ctx}
end
# Float
defp post_transform(value, ctx) when is_float(value) do
{{:literal, [subtype: :float], value}, ctx}
end
# String (binary)
defp post_transform(value, ctx) when is_binary(value) do
{{:literal, [subtype: :string], value}, ctx}
end
# Boolean
defp post_transform(true, ctx) do
{{:literal, [subtype: :boolean], true}, ctx}
end
defp post_transform(false, ctx) do
{{:literal, [subtype: :boolean], false}, ctx}
end
# Nil
defp post_transform(nil, ctx) do
{{:literal, [subtype: :null], nil}, ctx}
end
# Atom (not true/false/nil)
defp post_transform(atom, ctx) when is_atom(atom) do
{{:literal, [subtype: :symbol], atom}, ctx}
end
# ----- Lists -----
# List literal (already transformed children)
defp post_transform(list, ctx) when is_list(list) do
# Check if children are already MetaAST nodes
if Enum.all?(list, &meta_ast_node?/1) do
{{:list, [], list}, ctx}
else
# Mixed or keyword list - transform remaining elements
transformed = Enum.map(list, &ensure_meta_ast/1)
{{:list, [], transformed}, ctx}
end
end
# ----- Variables -----
# Variable reference
defp post_transform({name, meta, context}, ctx)
when is_atom(name) and is_atom(context) and not is_nil(context) do
node_meta = [scope: :local] ++ build_meta(meta)
{{:variable, node_meta, Atom.to_string(name)}, ctx}
end
# Variable with nil context
defp post_transform({name, meta, nil}, ctx) when is_atom(name) do
# Check if it's a special form
if special_form?(name) do
{{:literal, [subtype: :symbol] ++ build_meta(meta), name}, ctx}
else
node_meta = [scope: :local] ++ build_meta(meta)
{{:variable, node_meta, Atom.to_string(name)}, ctx}
end
end
# ----- Binary Operators -----
# Arithmetic operators
defp post_transform({op, meta, [left, right]}, ctx) when op in @arithmetic_ops do
node_meta = [category: :arithmetic, operator: op] ++ build_meta(meta)
{{:binary_op, node_meta, [left, right]}, ctx}
end
# String concatenation
defp post_transform({:<>, meta, [left, right]}, ctx) do
node_meta = [category: :string, operator: :<>] ++ build_meta(meta)
{{:binary_op, node_meta, [left, right]}, ctx}
end
# Comparison operators
defp post_transform({op, meta, [left, right]}, ctx) when op in @comparison_ops do
node_meta = [category: :comparison, operator: op] ++ build_meta(meta)
{{:binary_op, node_meta, [left, right]}, ctx}
end
# Boolean operators (normalize && -> :and, || -> :or for cross-language consistency)
defp post_transform({op, meta, [left, right]}, ctx) when op in @boolean_ops do
normalized = normalize_bool_op(op)
original_meta = if normalized != op, do: [original_op: op], else: []
node_meta = [category: :boolean, operator: normalized] ++ original_meta ++ build_meta(meta)
{{:binary_op, node_meta, [left, right]}, ctx}
end
# ----- Range Operators -----
# Range: 1..10
defp post_transform({:.., meta, [left, right]}, ctx) do
node_meta = build_meta(meta)
{{:range, node_meta, [left, right]}, ctx}
end
# Range with step: 1..10//2
defp post_transform({:..//, meta, [start, stop, step]}, ctx) do
node_meta = [step: step] ++ build_meta(meta)
{{:range, node_meta, [start, stop]}, ctx}
end
# ----- Unary Operators -----
# Negation (unary minus) - distinguish from binary minus by single arg
defp post_transform({:-, meta, [operand]}, ctx) do
node_meta = [category: :arithmetic, operator: :-] ++ build_meta(meta)
{{:unary_op, node_meta, [operand]}, ctx}
end
# Unary plus
defp post_transform({:+, meta, [operand]}, ctx) do
node_meta = [category: :arithmetic, operator: :+] ++ build_meta(meta)
{{:unary_op, node_meta, [operand]}, ctx}
end
# Logical not
defp post_transform({:not, meta, [operand]}, ctx) do
node_meta = [category: :boolean, operator: :not] ++ build_meta(meta)
{{:unary_op, node_meta, [operand]}, ctx}
end
defp post_transform({:!, meta, [operand]}, ctx) do
node_meta = [category: :boolean, operator: :!] ++ build_meta(meta)
{{:unary_op, node_meta, [operand]}, ctx}
end
# ----- Match Operator -----
defp post_transform({:=, meta, [pattern, value]}, ctx) do
node_meta = build_meta(meta)
{{:inline_match, node_meta, [pattern, value]}, ctx}
end
# ----- Maps -----
# Map literal - after traversal, pairs become {:tuple, [], [key_ast, value_ast]}
defp post_transform({:%{}, meta, pairs}, ctx) when is_list(pairs) do
# Transform pairs to {:pair, [], [key, value]} format
pair_nodes =
Enum.map(pairs, fn
# Map update
{:function_call, [{:name, "|"} | _], [struct, list]} ->
Logger.warning("Unexpected struct update: (#{inspect(struct)} with #{inspect(list)})")
# Already transformed as tuple: {:tuple, [], [key_ast, value_ast]}
{:tuple, _, [key_ast, value_ast]} ->
{:pair, [], [key_ast, value_ast]}
# Already a pair node
{:pair, _, _} = pair ->
pair
# Keyword-style pair {atom, value} - shouldn't happen after traversal but handle anyway
{key, value} when is_atom(key) ->
key_ast = {:literal, [subtype: :symbol], key}
{:pair, [], [key_ast, ensure_meta_ast(value)]}
# Other tuple - treat as key-value pair
{key, value} ->
{:pair, [], [ensure_meta_ast(key), ensure_meta_ast(value)]}
# Non-tuple element (e.g. already-transformed 3-tuple AST node) -- wrap as-is
other ->
ensure_meta_ast(other)
end)
node_meta = build_meta(meta)
{{:map, node_meta, pair_nodes}, ctx}
end
# ----- Tuples -----
# 2-element tuple shorthand
defp post_transform({left, right}, ctx)
when not is_atom(left) or left not in @meta_ast_types do
{{:tuple, [], [ensure_meta_ast(left), ensure_meta_ast(right)]}, ctx}
end
# N-element tuple
defp post_transform({:{}, meta, elements}, ctx) when is_list(elements) do
node_meta = build_meta(meta)
{{:tuple, node_meta, elements}, ctx}
end
# ----- Blocks -----
defp post_transform({:__block__, meta, statements}, ctx) when is_list(statements) do
node_meta = build_meta(meta)
{{:block, node_meta, statements}, ctx}
end
# ----- Conditionals -----
# if expression - clauses might be keyword list or transformed list
defp post_transform({:if, meta, [condition, clauses]}, ctx) do
{then_branch, else_branch} = extract_do_else(clauses)
node_meta = build_meta(meta)
{{:conditional, node_meta, [condition, then_branch, else_branch]}, ctx}
end
# unless expression (transform to conditional with negated condition)
defp post_transform({:unless, meta, [condition, clauses]}, ctx) do
{then_branch, else_branch} = extract_do_else(clauses)
negated = {:unary_op, [category: :boolean, operator: :not], [condition]}
node_meta = [original_form: :unless] ++ build_meta(meta)
{{:conditional, node_meta, [negated, then_branch, else_branch]}, ctx}
end
# cond expression - transform to nested conditionals
defp post_transform({:cond, meta, [clauses_wrapper]}, ctx) do
clauses = extract_do_clauses(clauses_wrapper)
nested = cond_to_nested_conditional(clauses)
node_meta = [original_form: :cond] ++ build_meta(meta)
# Merge meta into the top conditional
case nested do
{:conditional, inner_meta, children} ->
{{:conditional, Keyword.merge(inner_meta, node_meta), children}, ctx}
other ->
{other, ctx}
end
end
# ----- Case (Pattern Match) -----
defp post_transform({:case, meta, [scrutinee, clauses_wrapper]}, ctx) do
clauses = extract_do_clauses(clauses_wrapper)
arms =
Enum.map(clauses, fn
# Original format {:->, meta, [[pattern], body]}
{:->, clause_meta, [[pattern], body]} ->
arm_meta = build_meta(clause_meta)
{:match_arm, [pattern: pattern] ++ arm_meta, flatten_body(body)}
# Transformed as function_call
{:function_call, clause_meta, [{:list, _, [pattern]}, body]} ->
if Keyword.get(clause_meta, :name) == "->" do
arm_meta = build_meta(Keyword.get(clause_meta, :original_meta, []))
{:match_arm, [pattern: pattern] ++ arm_meta, flatten_body_ast(body)}
else
# Unexpected format
{:match_arm, [], [body]}
end
other ->
{:match_arm, [], [other]}
end)
node_meta = build_meta(meta)
{{:pattern_match, node_meta, [scrutinee | arms]}, ctx}
end
# ----- Function Calls -----
# Module alias - parts may be atoms (raw) or {:literal, _, atom} (transformed)
defp post_transform({:__aliases__, meta, parts}, ctx) when is_list(parts) do
module_name = safe_join_parts(parts)
node_meta = build_meta(meta)
{{:variable, node_meta, module_name}, ctx}
end
# Module alias with non-list parts (e.g. template variables inside quote blocks)
defp post_transform({:__aliases__, meta, non_list_parts}, ctx) do
node_meta = [language: :elixir, hint: :aliases_template] ++ build_meta(meta)
{{:language_specific, node_meta, non_list_parts}, ctx}
end
# Remote call: Module.function(args) - raw atom function name
defp post_transform({{:., _dot_meta, [module, func]}, meta, args}, ctx)
when is_atom(func) and is_list(args) do
handle_remote_call(module, func, args, meta, ctx)
end
# Remote call: Module.function(args) - transformed literal function name
defp post_transform({{:., _dot_meta, [module, {:literal, _, func}]}, meta, args}, ctx)
when is_atom(func) and is_list(args) do
handle_remote_call(module, func, args, meta, ctx)
end
# Remote call: Transformed function_call representing :. operator
# This catches cases where the inner :. got transformed to a function_call
defp post_transform({{:function_call, dot_meta, [module, func_literal]}, meta, args}, ctx)
when is_list(args) do
if Keyword.get(dot_meta, :name) == "." do
func = extract_func_name(func_literal)
handle_remote_call(module, func, args, meta, ctx)
else
# Not a remote call, pass through
{{:function_call, dot_meta, [module, func_literal]}, ctx}
end
end
# Local function call
defp post_transform({func, meta, args}, ctx)
when is_atom(func) and is_list(args) and
func not in [
:fn,
:def,
:defp,
:defmodule,
:defmacro,
:defmacrop,
:defprotocol,
:defimpl,
:defstruct,
:defexception,
:if,
:unless,
:cond,
:case,
:try,
:with,
:for,
:quote,
:unquote,
:@,
:&,
:|>,
:..,
:..//,
:import,
:use,
:require,
:alias
] do
node_meta = [name: Atom.to_string(func)] ++ build_meta(meta)
{{:function_call, node_meta, args}, ctx}
end
# ----- Anonymous Functions -----
# After traversal, clauses become {:function_call, [name: "->"], [params_list, body]}
defp post_transform({:fn, meta, clauses}, ctx) when is_list(clauses) do
case clauses do
# Single clause lambda - transformed clause format (check name inside body)
[{:function_call, clause_meta, [{:list, _, params}, body]}] ->
if Keyword.get(clause_meta, :name) == "->" do
param_names = extract_param_names_from_meta_ast(params)
node_meta = [params: param_names] ++ build_meta(meta)
{{:lambda, node_meta, flatten_body_ast(body)}, ctx}
else
# Unexpected structure - pass through
{{{:fn, meta, clauses}}, ctx}
end
# Original format (shouldn't happen after traversal but handle for safety)
[{:->, _clause_meta, [params, body]}] ->
param_names = extract_param_names(params)
node_meta = [params: param_names] ++ build_meta(meta)
{{:lambda, node_meta, flatten_body(body)}, ctx}
# Multi-clause
_ ->
arms =
Enum.map(clauses, fn
{:function_call, clause_meta, [{:list, _, params}, body]} ->
if Keyword.get(clause_meta, :name) == "->" do
pattern = params_to_pattern_meta_ast(params)
arm_meta = build_meta(Keyword.get(clause_meta, :original_meta, []))
{:match_arm, [pattern: pattern] ++ arm_meta, flatten_body_ast(body)}
else
{:match_arm, [], []}
end
{:->, clause_meta, [params, body]} ->
pattern = params_to_pattern(params)
arm_meta = build_meta(clause_meta)
{:match_arm, [pattern: pattern] ++ arm_meta, flatten_body(body)}
_ ->
{:match_arm, [], []}
end)
node_meta = [multi_clause: true] ++ build_meta(meta)
{{:lambda, node_meta, arms}, ctx}
end
end
# ----- Function Capture -----
# Note: Captures ({:&, meta, [body]}) are handled in pre_transform to prevent
# Macro.traverse from descending into the body and corrupting &1, &2 references.
# The {:lambda, meta, children} nodes returned from pre_transform will pass through here.
# ----- Module Definition -----
# After traversal, children are transformed:
# - name becomes {:variable, meta, "Module.Name"}
# - body becomes {:list, [], [{:tuple, [], [{:literal, :do}, body_ast]}]}
defp post_transform({:defmodule, meta, [name, body_container]}, ctx) do
module_name = extract_module_name_from_meta_ast(name)
body = extract_body_from_transformed(body_container)
body_statements = flatten_body_ast(body)
# Group consecutive function clauses with the same name/arity into a
# single :function_def node with clauses: metadata. This preserves the
# multi-clause dispatch semantics at the MetaAST level.
grouped_body = group_function_clauses(body_statements)
node_meta =
[
container_type: :module,
name: module_name,
module: module_name,
language: :elixir
] ++ build_meta(meta)
{{:container, node_meta, grouped_body}, ctx}
end
# ----- Function Definition -----
# After traversal, the children are already transformed:
# - signature becomes {:function_call, [name: "func_name"], [param vars...]}
# - body becomes {:list, [], [{:tuple, [], [{:literal, :do}, body_ast]}]}
defp post_transform({func_type, meta, [signature, body_container]}, ctx)
when func_type in [:def, :defp, :defmacro, :defmacrop] do
{func_name, params, guards} = extract_signature_from_meta_ast(signature)
visibility = if func_type in [:defp, :defmacrop], do: :private, else: :public
is_macro = func_type in [:defmacro, :defmacrop]
arity = length(params)
# Extract body from the transformed container
body = extract_body_from_transformed(body_container)
node_meta =
[
name: func_name,
params: params,
visibility: visibility,
arity: arity,
function: func_name,
language: :elixir
] ++ build_meta(meta)
# Add is_macro flag for macro definitions
node_meta = if is_macro, do: [is_macro: true] ++ node_meta, else: node_meta
# Add guards if present
node_meta = if guards, do: [guards: guards] ++ node_meta, else: node_meta
{{:function_def, node_meta, flatten_body_ast(body)}, ctx}
end
# ----- Protocol / Implementation / Behaviour Definitions -----
# defprotocol MyProtocol do ... end
# After traversal: name is {:variable, _, "MyProtocol"}, body_container is transformed
defp post_transform({:defprotocol, meta, [name, body_container]}, ctx) do
protocol_name = extract_module_name_from_meta_ast(name)
body = extract_body_from_transformed(body_container)
node_meta =
[
container_type: :protocol,
name: protocol_name,
module: protocol_name,
language: :elixir
] ++ build_meta(meta)
{{:container, node_meta, flatten_body_ast(body)}, ctx}
end
# defimpl MyProtocol, for: SomeType do ... end
# After traversal: second arg is keyword list with for: clause
defp post_transform({:defimpl, meta, [protocol_name_node, opts_and_body]}, ctx) do
protocol_name = extract_module_name_from_meta_ast(protocol_name_node)
body = extract_body_from_transformed(opts_and_body)
# Extract :for module from the options
for_type = extract_impl_for(opts_and_body)
impl_name = "#{protocol_name}.#{for_type}"
node_meta =
[
container_type: :impl,
name: impl_name,
module: impl_name,
protocol: protocol_name,
for: for_type,
language: :elixir
] ++ build_meta(meta)
{{:container, node_meta, flatten_body_ast(body)}, ctx}
end
# defstruct [:field1, :field2] or defstruct field1: default, ...
# Represent as a language_specific node since struct fields don't map cleanly
# to any M2 structural type without deeper semantics.
defp post_transform({:defstruct, meta, [fields]}, ctx) do
field_names = extract_struct_field_names(fields)
node_meta = [language: :elixir, hint: :defstruct, fields: field_names] ++ build_meta(meta)
{{:language_specific, node_meta, fields}, ctx}
end
# defexception [:message] or defexception message: "default"
defp post_transform({:defexception, meta, [fields]}, ctx) do
field_names = extract_struct_field_names(fields)
node_meta = [language: :elixir, hint: :defexception, fields: field_names] ++ build_meta(meta)
{{:language_specific, node_meta, fields}, ctx}
end
# ----- Module Attributes -----
# Handled via __attr_marker__ in pre/post_transform to prevent
# child traversal from corrupting the attribute name.
# ----- Try/Rescue -----
defp post_transform({:try, meta, [[do: try_block] ++ rest]}, ctx) do
rescue_clauses = Keyword.get(rest, :rescue, [])
catch_clauses = Keyword.get(rest, :catch, [])
after_block = Keyword.get(rest, :after)
handlers = transform_rescue_clauses(rescue_clauses ++ catch_clauses)
node_meta = build_meta(meta)
children = [try_block | handlers]
children = if after_block, do: children ++ [after_block], else: children
{{:exception_handling, node_meta, children}, ctx}
end
# ----- Pipe Operator -----
# Desugar a |> f(b) to f(a, b) — prepend left as first argument
defp post_transform({:|>, meta, [left, right]}, ctx) do
case right do
# x |> f(args...) → f(x, args...)
{:function_call, func_meta, args} ->
func_name = Keyword.get(func_meta, :name, "unknown")
node_meta = [name: func_name, pipe: true] ++ build_meta(meta)
{{:function_call, node_meta, [left | args]}, ctx}
# x |> f (no parens) — variable becomes zero-arity call: f(x)
{:variable, _var_meta, name} ->
node_meta = [name: name, pipe: true] ++ build_meta(meta)
{{:function_call, node_meta, [left]}, ctx}
# Can't desugar — keep as language_specific
_ ->
node_meta = [language: :elixir, hint: :pipe] ++ build_meta(meta)
{{:language_specific, node_meta, {:|>, meta, [left, right]}}, ctx}
end
end
# ----- With Expression -----
# Note: with expressions are now handled via __with_marker__ in pre/post_transform.
# ----- For Comprehension -----
# Note: for comprehensions are handled via __for_marker__ in pre/post_transform.
# ----- Attribute Access -----
# Map/struct field access: map.field
defp post_transform({{:., _dot_meta, [receiver, field]}, meta, []}, ctx)
when is_atom(field) do
node_meta = [attribute: Atom.to_string(field)] ++ build_meta(meta)
{{:attribute_access, node_meta, [receiver]}, ctx}
end
# ----- Catch-all -----
# Anything else passes through unchanged
defp post_transform(ast, ctx) do
{ast, ctx}
end
# ----- Helper Functions -----
defp handle_remote_call(module, func, args, meta, ctx) when is_atom(func) do
module_name = extract_module_name(module)
func_name = "#{module_name}.#{func}"
# Check for Enum operations
case {module_name, func, args} do
{"Enum", :map, [collection, func_arg]} ->
node_meta = [op_type: :map] ++ build_meta(meta)
{{:collection_op, node_meta, [func_arg, collection]}, ctx}
{"Enum", :filter, [collection, func_arg]} ->
node_meta = [op_type: :filter] ++ build_meta(meta)
{{:collection_op, node_meta, [func_arg, collection]}, ctx}
{"Enum", :reduce, [collection, initial, func_arg]} ->
node_meta = [op_type: :reduce] ++ build_meta(meta)
{{:collection_op, node_meta, [func_arg, collection, initial]}, ctx}
_ ->
node_meta = [name: func_name] ++ build_meta(meta)
{{:function_call, node_meta, args}, ctx}
end
end
defp extract_func_name({:literal, _, func}) when is_atom(func), do: func
defp extract_func_name(func) when is_atom(func), do: func
defp extract_func_name(_), do: :unknown
# Build metadata keyword list from Elixir AST meta.
# Promotes :line and :column to top-level keys and strips them from
# original_meta to avoid duplication.
# Promotes :__original_macro__ to :original_macro at the MetaAST level.
defp build_meta(elixir_meta) when is_list(elixir_meta) do
if Keyword.keyword?(elixir_meta) do
original_macro = Keyword.get(elixir_meta, :__original_macro__)
stripped = Keyword.drop(elixir_meta, [:line, :column, :__original_macro__])
base = if stripped == [], do: [], else: [original_meta: stripped]
base
|> maybe_add(:line, Keyword.get(elixir_meta, :line))
|> maybe_add(:col, Keyword.get(elixir_meta, :column))
|> maybe_add(:original_macro, original_macro)
else
# Non-keyword list (e.g. list of atoms inside quote blocks)
[original_meta: elixir_meta]
end
end
defp build_meta(_), do: []
defp maybe_add(keyword, _key, nil), do: keyword
defp maybe_add(keyword, key, value), do: Keyword.put(keyword, key, value)
# Safe Keyword.get that handles non-keyword lists
defp safe_keyword_get(list, key, default) do
if is_list(list) and Keyword.keyword?(list),
do: Keyword.get(list, key, default),
else: default
end
# Check if a value is already a MetaAST node
defp meta_ast_node?({type, meta, _children})
when is_atom(type) and is_list(meta) and type in @meta_ast_types do
true
end
defp meta_ast_node?(_), do: false
# Ensure a value is a MetaAST node
defp ensure_meta_ast(value) when is_integer(value) do
{:literal, [subtype: :integer], value}
end
defp ensure_meta_ast(value) when is_float(value) do
{:literal, [subtype: :float], value}
end
defp ensure_meta_ast(value) when is_binary(value) do
{:literal, [subtype: :string], value}
end
defp ensure_meta_ast(true), do: {:literal, [subtype: :boolean], true}
defp ensure_meta_ast(false), do: {:literal, [subtype: :boolean], false}
defp ensure_meta_ast(nil), do: {:literal, [subtype: :null], nil}
defp ensure_meta_ast(atom) when is_atom(atom) do
{:literal, [subtype: :symbol], atom}
end
defp ensure_meta_ast({type, meta, _children} = ast)
when is_atom(type) and is_list(meta) do
if meta_ast_node?(ast), do: ast, else: {:literal, [subtype: :symbol], ast}
end
defp ensure_meta_ast(other), do: other
# Check if binary parts contain interpolation
defp has_interpolation?(parts) do
Enum.any?(parts, fn
{:"::", _, _} -> true
_ -> false
end)
end
# Check if an atom is a special form
defp special_form?(name) do
name in [
:__block__,
:__aliases__,
:__MODULE__,
:__DIR__,
:__ENV__,
:__CALLER__,
:__STACKTRACE__,
:_,
:^,
:when,
:%{},
:{}
]
end
# Extract the :for type from a defimpl body container.
# The :for option is a keyword pair in the options list.
defp extract_impl_for({:list, _, items}) do
Enum.find_value(items, "Any", fn
{:tuple, _, [{:literal, [subtype: :symbol], :for}, type_node]} ->
extract_module_name_from_meta_ast(type_node)
{:pair, _, [{:literal, [subtype: :symbol], :for}, type_node]} ->
extract_module_name_from_meta_ast(type_node)
_ ->
nil
end)
end
defp extract_impl_for(list) when is_list(list) do
Keyword.get(list, :for, "Any") |> to_string()
end
defp extract_impl_for(_), do: "Any"
# Extract field names from defstruct/defexception argument.
# Accepts both list-of-atoms and keyword-list forms.
defp extract_struct_field_names({:list, _, elements}) do
Enum.flat_map(elements, fn
{:literal, [subtype: :symbol], name} -> [Atom.to_string(name)]
{:pair, _, [{:literal, [subtype: :symbol], name}, _]} -> [Atom.to_string(name)]
{:tuple, _, [{:literal, [subtype: :symbol], name}, _]} -> [Atom.to_string(name)]
_ -> []
end)
end
defp extract_struct_field_names(list) when is_list(list) do
Enum.flat_map(list, fn
{:literal, [subtype: :symbol], name} -> [Atom.to_string(name)]
{key, _} when is_atom(key) -> [Atom.to_string(key)]
_ -> []
end)
end
defp extract_struct_field_names(_), do: []
# Group consecutive :function_def nodes with the same (name, arity) into one
# node with a `clauses:` metadata key. Each clause entry is a map:
# %{params: [...], guard: ast_or_nil, body: [...]}
#
# Non-consecutive definitions (interleaved with other node types) are NOT
# grouped — they remain as separate :function_def nodes. Only direct
# neighbours in the same container body are merged.
defp group_function_clauses(body) when is_list(body) do
body
|> Enum.chunk_while(
[],
fn
{:function_def, meta, _body_stmts} = node, acc ->
name = Keyword.get(meta, :name)
arity = Keyword.get(meta, :arity, 0)
case acc do
[] ->
{:cont, [node]}
[{:function_def, prev_meta, _} | _] ->
prev_name = Keyword.get(prev_meta, :name)
prev_arity = Keyword.get(prev_meta, :arity, 0)
if name == prev_name and arity == prev_arity do
{:cont, [node | acc]}
else
# Different function — flush accumulated group, start new
{:cont, collapse_clause_group(acc), [node]}
end
_ ->
# Previous group was not function_defs — flush and start fresh
{:cont, acc, [node]}
end
other_node, [] ->
{:cont, [other_node]}
other_node, acc ->
# Non-function_def node encountered while accumulating clauses
{:cont, collapse_clause_group(acc), [other_node]}
end,
fn
[] -> {:cont, []}
acc -> {:cont, collapse_clause_group(acc), []}
end
)
|> List.flatten()
end
defp group_function_clauses(other), do: other
# Collapse a reversed list of :function_def nodes sharing (name, arity)
# into a single :function_def with `clauses:` metadata.
defp collapse_clause_group([single]), do: [single]
defp collapse_clause_group(reversed_nodes) when is_list(reversed_nodes) do
nodes = Enum.reverse(reversed_nodes)
# Extract clause descriptors from each node
clauses =
Enum.map(nodes, fn {:function_def, meta, body_stmts} ->
%{
params: Keyword.get(meta, :params, []),
guard: Keyword.get(meta, :guards),
body: body_stmts
}
end)
# Use the first clause's metadata as the representative definition
{:function_def, first_meta, _first_body} = hd(nodes)
# Build merged metadata: drop per-clause :guards, add :clauses list
merged_meta =
first_meta
|> Keyword.delete(:guards)
|> Keyword.put(:clauses, clauses)
|> Keyword.put(:multi_clause, true)
# Body becomes the first clause body (for backward compat) —
# callers that understand :clauses can use that instead.
[{:function_def, merged_meta, hd(nodes) |> elem(2)}]
end
defp collapse_clause_group(other), do: [other]
# Safely join module alias parts, tolerating non-atom elements
# that appear inside quote blocks (e.g. template variables, unquote expressions)
defp safe_join_parts(parts) when is_list(parts) do
Enum.map_join(parts, ".", fn
{:literal, _, atom} when is_atom(atom) -> Atom.to_string(atom)
atom when is_atom(atom) -> Atom.to_string(atom)
other -> inspect(other)
end)
end
# Extract module name from AST
defp extract_module_name({:__aliases__, _, parts}) when is_list(parts) do
safe_join_parts(parts)
end
defp extract_module_name({:__aliases__, _, _non_list}) do
"unknown"
end
defp extract_module_name({:variable, _, name}) when is_binary(name), do: name
# Handle transformed literal atoms (e.g., :telemetry becomes {:literal, _, :telemetry})
defp extract_module_name({:literal, _, atom}) when is_atom(atom), do: Atom.to_string(atom)
defp extract_module_name(atom) when is_atom(atom), do: Atom.to_string(atom)
defp extract_module_name({name, _, _}) when is_atom(name), do: Atom.to_string(name)
defp extract_module_name(_), do: "unknown"
# Extract parameter names (for lambdas)
defp extract_param_names(params) when is_list(params) do
Enum.map(params, fn
{name, _, context} when is_atom(name) and is_atom(context) ->
{:param, [], Atom.to_string(name)}
_ ->
{:param, [], "_"}
end)
end
defp extract_param_names(_), do: []
# Convert params list to pattern for multi-clause lambdas
defp params_to_pattern([single]), do: single
defp params_to_pattern(params), do: {:tuple, [], params}
# Extract parameter names from already-transformed MetaAST params
defp extract_param_names_from_meta_ast(params) when is_list(params) do
Enum.map(params, fn
{:variable, _, name} -> {:param, [], name}
_ -> {:param, [], "_"}
end)
end
defp extract_param_names_from_meta_ast(_), do: []
# Convert transformed params list to pattern for multi-clause lambdas
defp params_to_pattern_meta_ast([single]), do: single
defp params_to_pattern_meta_ast(params), do: {:tuple, [], params}
# Flatten body to list of statements (for raw Elixir AST)
defp flatten_body({:__block__, _, statements}), do: statements
defp flatten_body({:block, _, statements}), do: statements
defp flatten_body(nil), do: []
defp flatten_body(single), do: [single]
# Flatten body that's already transformed to MetaAST
defp flatten_body_ast({:block, _, statements}), do: statements
defp flatten_body_ast(nil), do: []
defp flatten_body_ast({:literal, [subtype: :null], nil}), do: []
defp flatten_body_ast(single), do: [single]
# Extract function signature info from transformed MetaAST
# With args: signature becomes {:function_call, [name: "func_name"], [params...]}
# Without args: signature becomes {:variable, meta, "func_name"}
#
# Guarded functions: after Macro.traverse, `{:when, meta, [sig, guard]}`
# becomes `{:function_call, [name: "when"], [sig_transformed, guard_transformed]}`.
# We detect this pattern and extract the real signature + guard.
# Guarded function: {:function_call, [name: "when"], [real_sig, guard_ast]}
defp extract_signature_from_meta_ast({:function_call, meta, [real_sig, guard_ast]})
when is_list(meta) do
if Keyword.get(meta, :name) == "when" do
{func_name, params, _} = extract_signature_from_meta_ast(real_sig)
{func_name, params, guard_ast}
else
extract_signature_as_call(meta, [real_sig, guard_ast])
end
end
# Regular function call signature with 1 or 3+ params
defp extract_signature_from_meta_ast({:function_call, meta, params}) when is_list(meta) do
extract_signature_as_call(meta, params)
end
# Zero-arity function: signature becomes a variable
defp extract_signature_from_meta_ast({:variable, _, func_name}) do
{func_name, [], nil}
end
# Fallback
defp extract_signature_from_meta_ast(_), do: {"anonymous", [], nil}
defp extract_signature_as_call(meta, params) do
func_name = Keyword.get(meta, :name, "anonymous")
param_list =
Enum.map(params, fn
{:variable, _, name} -> {:param, [], name}
_ -> {:param, [], "_"}
end)
{func_name, param_list, nil}
end
# Extract module name from transformed MetaAST
# The name becomes {:variable, meta, "Module.Name"}
defp extract_module_name_from_meta_ast({:variable, _, name}), do: name
defp extract_module_name_from_meta_ast({:literal, _, atom}) when is_atom(atom),
do: Atom.to_string(atom)
defp extract_module_name_from_meta_ast(_), do: "unknown"
# Extract body from transformed container
# Body becomes {:list, [], [{:tuple, [], [{:literal, :do}, body_ast]}]}
defp extract_body_from_transformed({:list, _, items}) do
# Find the :do item
Enum.find_value(items, nil, fn
{:tuple, _, [{:literal, [subtype: :symbol], :do}, body]} -> body
{:pair, _, [{:literal, [subtype: :symbol], :do}, body]} -> body
_ -> nil
end)
end
defp extract_body_from_transformed([{:tuple, _, [{:literal, [subtype: :symbol], :do}, body]}]) do
body
end
defp extract_body_from_transformed(_), do: nil
# Extract do/else branches from clauses (handles both raw keyword and transformed list)
defp extract_do_else(clauses) when is_list(clauses) do
# Check if this is a keyword list [do: x, else: y] or transformed list
case clauses do
[{key, _value} | _] when is_atom(key) ->
# Raw keyword list
then_branch = Keyword.get(clauses, :do)
else_branch = Keyword.get(clauses, :else)
{then_branch, else_branch}
_ ->
# Not a keyword list - might be transformed or something else
{nil, nil}
end
end
defp extract_do_else({:list, _, items}) do
# Transformed list: {:list, [], [{:tuple, [], [{:literal, :do}, x]}, ...]}
then_branch =
Enum.find_value(items, nil, fn
{:tuple, _, [{:literal, [subtype: :symbol], :do}, body]} -> body
{:pair, _, [{:literal, [subtype: :symbol], :do}, body]} -> body
_ -> nil
end)
else_branch =
Enum.find_value(items, nil, fn
{:tuple, _, [{:literal, [subtype: :symbol], :else}, body]} -> body
{:pair, _, [{:literal, [subtype: :symbol], :else}, body]} -> body
_ -> nil
end)
{then_branch, else_branch}
end
defp extract_do_else(_), do: {nil, nil}
# Extract do clauses from wrapper (handles both raw keyword and transformed)
defp extract_do_clauses(do: clauses) when is_list(clauses), do: clauses
defp extract_do_clauses(do: single), do: [single]
defp extract_do_clauses({:list, _, items}) do
# Find :do item and extract its content
Enum.find_value(items, [], fn
{:tuple, _, [{:literal, [subtype: :symbol], :do}, {:list, _, clauses}]} -> clauses
{:pair, _, [{:literal, [subtype: :symbol], :do}, {:list, _, clauses}]} -> clauses
{:tuple, _, [{:literal, [subtype: :symbol], :do}, body]} -> [body]
{:pair, _, [{:literal, [subtype: :symbol], :do}, body]} -> [body]
_ -> nil
end)
end
defp extract_do_clauses(_), do: []
# Transform cond clauses to nested conditionals
defp cond_to_nested_conditional([]), do: {:literal, [subtype: :null], nil}
defp cond_to_nested_conditional([{:->, _, [[condition], body]} | rest]) do
else_branch = cond_to_nested_conditional(rest)
{:conditional, [], [condition, body, else_branch]}
end
# Handle transformed cond clauses
defp cond_to_nested_conditional([
{:function_call, meta, [{:list, _, [condition]}, body]} | rest
]) do
if Keyword.get(meta, :name) == "->" do
else_branch = cond_to_nested_conditional(rest)
{:conditional, [], [condition, body, else_branch]}
else
{:literal, [subtype: :null], nil}
end
end
defp cond_to_nested_conditional(_), do: {:literal, [subtype: :null], nil}
# ----- With Expression transformation (called from pre_transform marker) -----
defp transform_with(args, meta) do
# Split: all but last are clauses, last is keyword opts [do: body, else: ...]
{clauses, [opts]} = Enum.split(args, -1)
body = Keyword.get(opts, :do)
else_clauses = Keyword.get(opts, :else, [])
# Transform each with clause to inline_match
inline_matches = Enum.map(clauses, &transform_with_clause/1)
# Transform body
{:ok, body_ast, _} = transform(body)
# Build statements: clauses + body
statements = inline_matches ++ flatten_body_ast(body_ast)
if else_clauses == [] do
# Simple with (no else): block of inline_matches + body
node_meta = [original_form: :with] ++ build_meta(meta)
{:block, node_meta, statements}
else
# With + else: append pattern_match for else clauses
else_arms =
Enum.map(else_clauses, fn {:->, clause_meta, [[pattern], clause_body]} ->
{:ok, pattern_ast, _} = transform(pattern)
{:ok, _clause_body_ast, _} = transform(clause_body)
{:match_arm, [pattern: pattern_ast] ++ build_meta(clause_meta),
flatten_body(clause_body)}
end)
# Placeholder scrutinee for the else pattern match
scrutinee = {:variable, [], "_with_result"}
else_node =
{:pattern_match, [original_form: :with_else] ++ build_meta(meta), [scrutinee | else_arms]}
node_meta = [original_form: :with] ++ build_meta(meta)
{:block, node_meta, statements ++ [else_node]}
end
end
defp transform_with_clause({:<-, clause_meta, [pattern, expr]}) do
{:ok, pattern_ast, _} = transform(pattern)
{:ok, expr_ast, _} = transform(expr)
{:inline_match, [original_form: :with_clause] ++ build_meta(clause_meta),
[pattern_ast, expr_ast]}
end
defp transform_with_clause({:=, clause_meta, [pattern, expr]}) do
{:ok, pattern_ast, _} = transform(pattern)
{:ok, expr_ast, _} = transform(expr)
{:inline_match, build_meta(clause_meta), [pattern_ast, expr_ast]}
end
defp transform_with_clause(other) do
{:ok, ast, _} = transform(other)
ast
end
# Transform rescue/catch clauses
defp transform_rescue_clauses(clauses) do
Enum.map(clauses, fn
{:->, meta, [[pattern], body]} ->
node_meta = [pattern: pattern] ++ build_meta(meta)
{:match_arm, node_meta, flatten_body(body)}
end)
end
# Extract captured function name
defp extract_captured_function_name({{:., _, [module, func]}, _, []}) do
"#{extract_module_name(module)}.#{func}"
end
defp extract_captured_function_name({func, _, _}) when is_atom(func) do
Atom.to_string(func)
end
defp extract_captured_function_name(_), do: "unknown"
# Transform for comprehension (called from pre_transform marker)
# Elixir for args: [gen1, gen2, filter1, gen3, filter2, [do: body, into: target]]
# The last element is always the keyword opts list.
defp transform_for(args, meta) do
# Split: last element is opts keyword list, everything before is clauses
{clauses, [opts]} = Enum.split(args, -1)
body = Keyword.get(opts, :do)
{:ok, body_ast, _} = transform(body)
into = Keyword.get(opts, :into)
# Transform generators and filters (interleaved)
generators_and_filters =
Enum.map(clauses, fn
{:<-, gen_meta, [var, collection]} ->
{:ok, var_ast, _} = transform(var)
{:ok, coll_ast, _} = transform(collection)
{:generator, build_meta(gen_meta), [var_ast, coll_ast]}
filter_expr ->
{:ok, filter_ast, _} = transform(filter_expr)
{:filter, [], [filter_ast]}
end)
node_meta = build_meta(meta)
node_meta = if into, do: [into: into] ++ node_meta, else: node_meta
{:comprehension, node_meta, [body_ast | generators_and_filters]}
end
# Extract function name and arity from type annotation value
defp extract_type_annotation_info(attr_type, value)
when attr_type in [:spec, :callback, :macrocallback] do
case value do
# @spec func(args) :: return_type
{:"::", _, [{func_name, _, args} | _]} when is_atom(func_name) and is_list(args) ->
{Atom.to_string(func_name), length(args)}
# @spec func :: return_type (no args)
{:"::", _, [{func_name, _, nil} | _]} when is_atom(func_name) ->
{Atom.to_string(func_name), 0}
# @spec func(args) :: return_type with when clause
{:when, _, [{:"::", _, [{func_name, _, args} | _]} | _]}
when is_atom(func_name) and is_list(args) ->
{Atom.to_string(func_name), length(args)}
_ ->
{"unknown", 0}
end
end
defp extract_type_annotation_info(_attr_type, value) do
case value do
# @type name :: type_expr
{:"::", _, [{name, _, _} | _]} when is_atom(name) ->
{Atom.to_string(name), 0}
_ ->
{"unknown", 0}
end
end
end