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