aprs.me/lib/mix_unused/analyzer.ex
Graham McIntire 6f1d4eee4e
Fix Analyzer.debug_info_line to return nil on with-fallthrough
When :beam_lib.chunks fails (e.g. a project module hasn't been loaded
into :code.which yet), the existing with returned the raw error tuple
as the 'line' value, which then crashed format/1 via String.Chars.

Add an explicit else clause so the function always returns nil or an
integer.
2026-05-09 11:26:41 -05:00

347 lines
10 KiB
Elixir

defmodule MixUnused.Analyzer do
@moduledoc """
Reads compiled BEAM files via Erlang's built-in `:xref` cross-reference
analyser and computes the set of public functions that no caller in the
project ever invokes.
This is more reliable than a tracer-based approach because it works against
the already-compiled artifacts and doesn't require re-running the compiler
with a tracer attached.
"""
@type unused_entry :: %{
module: module(),
name: atom(),
arity: arity(),
file: String.t() | nil,
line: pos_integer() | nil
}
@doc """
Returns a sorted list of unused public functions discovered in the given
compile path (defaults to `Mix.Project.compile_path/0`).
## Options
* `:exclude` — list of `module` atoms or `{module, atom, arity}` triples
that should never appear in the output.
* `:compile_path` — the directory containing the project's `*.beam`
files (defaults to `Mix.Project.compile_path/0`).
* `:app` — only consider modules belonging to this OTP application
(defaults to the current Mix project's `:app` setting).
"""
@spec analyze(keyword()) :: [unused_entry()]
def analyze(opts \\ []) do
state = Keyword.get_lazy(opts, :precomputed_state, fn -> compute_state(opts) end)
filter_state(state, opts)
end
@doc """
Computes the heavy parts of analysis (project modules, defs, calls) once.
Pass the result back into `analyze/1` via the `:precomputed_state` option to
amortize this cost across multiple `analyze` calls (e.g. in tests that
re-run analysis with different `:exclude` filters).
"""
@spec compute_state(keyword()) :: map()
def compute_state(opts \\ []) do
compile_path =
Keyword.get_lazy(opts, :compile_path, fn -> Mix.Project.compile_path() end)
app = Keyword.get_lazy(opts, :app, fn -> Keyword.get(Mix.Project.config(), :app) end)
project_modules = collect_project_modules(compile_path, app)
project_module_set = MapSet.new(project_modules)
%{
defs: collect_definitions(project_modules),
calls: collect_calls(project_modules),
project_module_set: project_module_set
}
end
defp filter_state(%{defs: defs, calls: calls, project_module_set: project_module_set}, opts) do
excluded = Keyword.get(opts, :exclude, [])
excluded_modules = MapSet.new(for m <- excluded, is_atom(m), do: m)
excluded_mfas = MapSet.new(for {m, f, a} <- excluded, do: {m, f, a})
defs
|> Enum.reject(fn {{m, f, a}, _meta} ->
MapSet.member?(excluded_modules, m) or
MapSet.member?(excluded_mfas, {m, f, a}) or
MapSet.member?(calls, {m, f, a}) or
skip_def?(m, f, a, project_module_set)
end)
|> Enum.map(fn {{m, f, a}, %{file: file, line: line}} ->
%{module: m, name: f, arity: a, file: file, line: line}
end)
|> Enum.sort_by(fn e -> {e.module, e.name, e.arity} end)
end
# ── Project module discovery ──────────────────────────────────────────────
defp collect_project_modules(compile_path, app) do
case File.ls(compile_path) do
{:ok, files} ->
for file <- files,
String.ends_with?(file, ".beam"),
module = beam_to_module(file),
in_app?(module, app),
do: module
_ ->
[]
end
end
defp beam_to_module(file) do
file
|> String.replace_suffix(".beam", "")
|> String.to_atom()
end
# When app is nil we can't filter by application — return all modules.
defp in_app?(_module, nil), do: true
defp in_app?(module, app) do
case Application.get_application(module) do
^app -> true
_ -> false
end
end
# ── Definition collection ─────────────────────────────────────────────────
defp collect_definitions(modules) do
Enum.flat_map(modules, &module_definitions/1)
end
defp module_definitions(module) do
Code.ensure_loaded(module)
file = source_file(module)
attrs = module.__info__(:attributes)
impls = attrs |> Keyword.get_values(:impl) |> List.flatten()
callbacks = attrs |> Keyword.get_values(:callback) |> List.flatten()
behaviours = attrs |> Keyword.get_values(:behaviour) |> List.flatten()
module
|> module_exports()
|> Enum.reject(fn {f, a} ->
callback?(f, a, impls, callbacks, behaviours)
end)
|> Enum.map(fn {f, a} ->
line = function_line(module, f, a)
{{module, f, a}, %{file: file, line: line}}
end)
rescue
_ -> []
catch
_, _ -> []
end
defp module_exports(module) do
fns = module.__info__(:functions)
macros = module.__info__(:macros)
fns ++ macros
rescue
_ -> []
end
defp source_file(module) do
case module.module_info(:compile)[:source] do
nil -> nil
source -> source |> List.to_string() |> normalize_path()
end
rescue
_ -> nil
end
defp normalize_path(path) do
cwd = File.cwd!()
if String.starts_with?(path, cwd <> "/"), do: Path.relative_to_cwd(path), else: path
end
defp function_line(module, name, arity) do
case module.module_info(:compile)[:source] do
nil ->
nil
_source ->
# Try to extract from BEAM debug_info if available.
debug_info_line(module, name, arity)
end
rescue
_ -> nil
end
defp debug_info_line(module, name, arity) do
with {:ok, {_, [{:debug_info, {:debug_info_v1, backend, data}}]}} <-
:beam_lib.chunks(beam_path(module), [:debug_info]),
{:ok, %{definitions: defs}} <- backend.debug_info(:elixir_v1, module, data, []) do
Enum.find_value(defs, fn
{{^name, ^arity}, _kind, meta, _clauses} -> Keyword.get(meta, :line)
_ -> nil
end)
else
_ -> nil
end
rescue
_ -> nil
catch
_, _ -> nil
end
defp beam_path(module) do
case :code.which(module) do
path when is_list(path) -> path
_ -> []
end
end
defp callback?(name, arity, impls, callbacks, behaviours) do
{name, arity} in callbacks or
Enum.any?(impls, fn
true -> false
false -> false
m when is_atom(m) -> behaviour_callback?(m, name, arity)
_ -> false
end) or
Enum.any?(behaviours, fn b -> behaviour_callback?(b, name, arity) end)
end
defp behaviour_callback?(behaviour, name, arity) do
callbacks = behaviour.behaviour_info(:callbacks)
{name, arity} in callbacks
rescue
_ -> false
catch
_, _ -> false
end
# ── Call collection ───────────────────────────────────────────────────────
defp collect_calls(modules) do
modules
|> Enum.flat_map(&module_calls/1)
|> MapSet.new()
end
defp module_calls(module) do
case :beam_lib.chunks(beam_path(module), [:debug_info]) do
{:ok, {_, [{:debug_info, {:debug_info_v1, _backend, _data}}]}} ->
# Walk debug info looking for remote calls.
# Use Erlang's xref-style approach: scan abstract code via beam_lib.
case :beam_lib.chunks(beam_path(module), [:abstract_code]) do
{:ok, {_, [{:abstract_code, {:raw_abstract_v1, forms}}]}} ->
extract_calls_from_forms(forms)
_ ->
[]
end
_ ->
[]
end
rescue
_ -> []
catch
_, _ -> []
end
defp extract_calls_from_forms(forms) do
Enum.flat_map(forms, &extract_calls_from_form/1)
end
defp extract_calls_from_form({:function, _line, _name, _arity, clauses}) do
Enum.flat_map(clauses, &walk_form/1)
end
defp extract_calls_from_form(_), do: []
defp walk_form({:call, _line, {:remote, _, {:atom, _, m}, {:atom, _, f}}, args}) do
[{m, f, length(args)} | Enum.flat_map(args, &walk_form/1)]
end
defp walk_form(tuple) when is_tuple(tuple) do
tuple |> Tuple.to_list() |> Enum.flat_map(&walk_form/1)
end
defp walk_form(list) when is_list(list) do
Enum.flat_map(list, &walk_form/1)
end
defp walk_form(_), do: []
# ── Skip rules ────────────────────────────────────────────────────────────
@entry_points MapSet.new([
:__info__,
:__struct__,
:__changeset__,
:__schema__,
:module_info,
:child_spec,
:start_link,
:start,
:init,
:handle_call,
:handle_cast,
:handle_info,
:handle_continue,
:terminate,
:code_change,
:format_status,
:handle_event,
:handle_params,
:handle_async,
:mount,
:render,
:update,
:__live__,
:__components__,
:main,
:run
])
defp skip_def?(_module, name, _arity, _project_modules) do
name_str = Atom.to_string(name)
MapSet.member?(@entry_points, name) or
String.starts_with?(name_str, "__") or
String.starts_with?(name_str, "MACRO-")
end
# ── Formatting ────────────────────────────────────────────────────────────
@doc "Format a list of unused entries as a human-readable string."
@spec format([unused_entry()]) :: String.t()
def format([]), do: "No unused public functions found.\n"
def format(entries) do
grouped = Enum.group_by(entries, & &1.module)
body =
grouped
|> Enum.sort_by(fn {module, _} -> Atom.to_string(module) end)
|> Enum.map_join("\n", &format_module_block/1)
"Unused public functions:\n\n" <> body <> "\n"
end
defp format_module_block({module, items}) do
lines = Enum.map_join(items, "\n", &format_entry_line/1)
"#{inspect(module)}\n#{lines}"
end
defp format_entry_line(%{name: n, arity: a, file: f, line: l}) do
location =
cond do
f && l -> "#{f}:#{l}"
f -> f
true -> "?"
end
" def #{n}/#{a} (#{location})"
end
end