beam-elixir-ast-gotchas
Fix Elixir/Erlang AST processing bugs in Grafema beam-analyzer. Use when: (1) Elixir parser returns MODULE node but 0 functions/calls — body nesting issue, (2) Erlang parser crashes with "cannot conve
SKILL.md.claude/skills/beam-elixir-ast-gotchas/SKILL.md
GitHubBEAM/Elixir AST Processing Gotchas
Problem
When building tools that process Elixir/Erlang ASTs (like Grafema's beam-analyzer), several non-obvious AST structural issues cause silent failures or crashes.
Context / Trigger Conditions
Gotcha 1: Elixir AST Body Double-Nesting
- Symptom: MODULE node created correctly, but 0 FUNCTION/CALL/VARIABLE nodes
- Trigger: Any Elixir file with functions inside a module
- Root cause:
{:defmodule, meta, [alias | body]}producesbody = [[do: ...]], NOTbody = [do: ...]. The body is wrapped in an extra list layer. - Same issue for functions:
{:def, meta, [{name, _, args} | body]}also givesbody = [[do: ...]]
Fix: Always unwrap with List.first(body) || [] before pattern matching on [do: ...]:
# WRONG — body is [[do: ...]], not [do: ...]
walk_module_body(body, ctx)
# RIGHT
keyword_body = List.first(body) || []
walk_module_body(keyword_body, ctx)
Gotcha 2: OTP 26+ Erlang AST Location Format
- Symptom:
(ArgumentError) cannot convert the given list to a string - Trigger: Processing Erlang
.erlfiles with OTP 26 or newer - Root cause: OTP 26 changed Erlang abstract forms from
{:attribute, LineNumber, ...}to{:attribute, [text: ~c"...", location: N], ...}. The second element is now a keyword list, not an integer.
Fix: Add a location extractor helper:
defp extract_line(loc) when is_integer(loc), do: loc
defp extract_line(loc) when is_list(loc), do: Keyword.get(loc, :location, 0)
defp extract_line(_), do: 0
Apply to ALL Erlang form handlers: :module, :export, :function, :type, :spec,
:import, :call, :match, :case, etc.
Gotcha 3: Pipe Operator Clause Ordering
- Symptom: CALL nodes with name
|>instead of desugared function names - Trigger: Elixir pipe chains like
data |> String.trim() |> Enum.map(...) - Root cause:
{:|>, meta, [left, right]}matches the general clause{name, meta, args} when is_atom(name) and is_list(args)because:|>is an atom. If the general clause is defined before the pipe-specific clause, it catches pipes first.
Fix: Always define the {:|>, ...} clause BEFORE {name, meta, args}:
# Pipe clause MUST come first
defp walk_pipe_arg({:|>, _meta, [left, right]}, ctx) do ...
defp walk_pipe_arg({{:., _, _}, meta, args}, ctx) do ... # dot calls
defp walk_pipe_arg({name, meta, args}, ctx) when is_atom(name) do ... # general - LAST
Gotcha 4: Multi-Module Files
- Symptom: Only first module processed, or 0 nodes if top-level is
__block__ - Trigger: Elixir files with multiple
defmoduleat the top level - Root cause:
Code.string_to_quotedreturns{:__block__, _, [defmodule1, defmodule2, ...]}for multi-module files, but{:defmodule, ...}for single-module files.
Fix: Handle both in the walker:
defp walk_elixir({:__block__, _, statements}, ctx) do
Enum.reduce(statements, ctx, fn stmt, ctx -> Rules.Modules.process(stmt, ctx) end)
end
defp walk_elixir(ast, ctx), do: Rules.Modules.process(ast, ctx)
Gotcha 5: ::: Atom Quoting Warning
- Symptom: Elixir 1.16+ warning:
atom ::: must be written between quotes - Trigger: Pattern matching on typespec AST
{:::, _, [...]} - Fix: Use
{:"::", _, [...]}instead of{:::, _, [...]}
Verification
- Run
mix run verify.exson comprehensive fixture files - Check that all expected node types appear (MODULE, FUNCTION, CALL, VARIABLE, etc.)
- Verify Erlang files produce nodes without crashes
- Verify pipe chains produce individual CALL nodes per function
Notes
- The Elixir AST with
columns: true, token_metadata: trueoptions adds extra metadata to every node — be aware when pattern matching - OTP version detection: check
:erlang.system_info(:otp_release)if you need conditional handling - For debugging AST structure, use
Code.string_to_quoted(source) |> IO.inspect(pretty: true)
Installing Erlang/Elixir on macOS with Outdated Clang
If brew install erlang fails with a clang crash on PCRE2 (Clang 14.x):
- Install kerl:
brew install kerl - Build with problematic deps disabled:
KERL_CONFIGURE_OPTIONS="--without-wx --without-odbc --without-javac --disable-jit" \ kerl build 26.2.5 26.2.5 kerl install 26.2.5 ~/.kerl/installs/26.2.5 source ~/.kerl/installs/26.2.5/activate - Install Elixir from pre-built binary (match OTP version):
curl -fsSL https://github.com/elixir-lang/elixir/releases/download/v1.16.3/elixir-otp-26.zip \ -o /tmp/elixir.zip unzip -q /tmp/elixir.zip -d ~/.elixir export PATH="~/.kerl/installs/26.2.5/bin:~/.elixir/bin:$PATH"