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.
347 lines
10 KiB
Elixir
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
|