Packages
metastatic
0.26.0
0.26.0
0.25.0
0.24.1
0.24.0
0.23.0
0.22.2
0.22.1
0.22.0
0.21.3
0.21.2
0.21.1
0.21.0
0.20.3
0.20.2
0.20.1
0.20.0
0.19.0
0.18.0
0.17.0
0.16.0
0.15.1
0.15.0
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.0
0.11.0
0.10.4
0.10.3
0.10.2
0.10.1
0.10.0
0.9.2
0.9.1
0.9.0
0.8.6
0.8.5
0.8.4
0.8.3
0.8.2
0.8.1
0.8.0
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.1
0.7.0
0.6.1
0.6.0
0.5.2
0.5.1
0.5.0
0.4.2
0.4.1
0.4.0
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.3
0.1.2
0.1.1
0.1.0
Cross-language code meta-model library using unified MetaAST representation. Parse, transform, and translate code across Python, Elixir, Ruby, Erlang, Haskell, and more via a shared three-tuple AST format.
Current section
Files
Jump to
Current section
Files
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