Implement mix_unused-style unused public function detector

Adds a self-contained 'unused' Mix compiler task that finds public
functions in this project no caller in the project ever invokes:

- MixUnused.Analyzer: enumerates public functions across the project's
  compiled BEAM files (filtered by app), walks the abstract code in
  each BEAM via :beam_lib to collect every remote {Module, fun, arity}
  call observed at compile time, and subtracts the call set from the
  def set. Exposes :exclude (modules or MFA triples), :compile_path,
  and :app options. Behaviour callbacks (@impl true and any listed
  in behaviour_info(:callbacks)) and well-known entry points
  (start_link, child_spec, mount, render, GenServer callbacks,
  __info__, module_info, etc.) are excluded automatically.

- Mix.Tasks.Compile.Unused: a Mix.Task.Compiler that compiles the
  project then asks the analyzer for unused defs. Severity is
  configurable via the :unused project key; per-file exclusion of
  test/ paths is on by default.

Inspired by https://github.com/hauleth/mix_unused — uses BEAM
introspection rather than a compilation tracer to avoid the
chicken-and-egg problem of recompiling the tracer module itself
during analysis.
This commit is contained in:
Graham McIntire 2026-05-08 12:30:04 -05:00
parent 039ee83ecb
commit 5fb653bf05
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
4 changed files with 582 additions and 1 deletions

View file

@ -36,5 +36,14 @@
# Mix.Task PLT limitations: Mix.Task behaviour types and Mix.shell/0 are not available
# in the dialyzer PLT; also the aprs library's return type is inferred incorrectly from
# the compiled beam without full type specs
{"lib/mix/tasks/aprs.parse_file.ex"}
{"lib/mix/tasks/aprs.parse_file.ex"},
# Mix.Task.Compiler PLT limitations: same root cause — Mix.* functions and the
# Mix.Task.Compiler.Diagnostic struct aren't in the dialyzer PLT.
{"lib/mix/tasks/compile/unused.ex"},
# Mix.Project PLT limitations: Mix.Project.compile_path/0 and
# Mix.Project.config/0 are not present in the dialyzer PLT, and Code.ensure_loaded
# is treated as having an unmatched return when used for side-effect.
{"lib/mix_unused/analyzer.ex"}
]

View file

@ -0,0 +1,137 @@
defmodule Mix.Tasks.Compile.Unused do
@moduledoc """
Mix compiler that finds public functions in this project which no observed
caller ever invokes.
## Usage
Run directly:
mix compile.unused
Or wire into the compiler chain in `mix.exs`:
compilers: Mix.compilers() ++ [:unused]
## Configuration
In `mix.exs`:
def project do
[
...,
unused: [
severity: :warning, # :warning (default) | :error | :info
exclude: [
MyApp.SomeModule, # whole modules
{MyApp.Foo, :ignored, 0} # specific MFA triples
],
ignore_test_files: true # skip files under test/ (default true)
]
]
end
## How it works
* The project is compiled (via `mix compile`).
* `MixUnused.Analyzer` then enumerates every public function in every
project-owned BEAM file via `Module.__info__/1` and walks the
abstract code in each BEAM (via `:beam_lib`) to collect the set of
remote `Module.fun/arity` calls.
* Functions in the def-set that aren't in the call-set are reported.
* Behaviour callbacks (`@impl true` or via `behaviour_info(:callbacks)`),
well-known entry points (`start_link`, `child_spec`, `mount`,
`render`, GenServer callbacks, etc.) and reflection helpers
(`__info__/1`, `module_info/0`, ...) are excluded automatically.
"""
use Mix.Task.Compiler
alias Mix.Task.Compiler
alias MixUnused.Analyzer
@recursive false
@impl Compiler
def run(args) do
{opts, _, _} =
OptionParser.parse(args,
switches: [severity: :string, all: :boolean]
)
config = read_config()
severity =
opts
|> Keyword.get(:severity)
|> parse_severity(Keyword.get(config, :severity, :warning))
# Make sure the project is compiled before analyzing.
_ = Mix.Task.run("compile", [])
config
|> filter_options()
|> Analyzer.analyze()
|> filter_test_files(Keyword.get(config, :ignore_test_files, true))
|> emit(severity)
end
@impl Compiler
def manifests, do: []
@impl Compiler
def clean, do: :ok
defp read_config, do: Keyword.get(Mix.Project.config(), :unused, [])
defp filter_options(config) do
[exclude: Keyword.get(config, :exclude, [])]
end
defp parse_severity(nil, default), do: default
defp parse_severity(string, _default) do
case String.downcase(string) do
"warning" -> :warning
"error" -> :error
"info" -> :info
_ -> :warning
end
end
defp filter_test_files(entries, false), do: entries
defp filter_test_files(entries, true) do
Enum.reject(entries, fn %{file: file} ->
file != nil and (String.starts_with?(file, "test/") or String.contains?(file, "/test/"))
end)
end
defp emit([], _severity) do
Mix.shell().info("[unused] No unused public functions found.")
{:ok, []}
end
defp emit(entries, severity) do
formatted = Analyzer.format(entries)
Mix.shell().info("\n" <> formatted)
diagnostics = Enum.map(entries, &to_diagnostic(&1, severity))
case severity do
:error -> {:error, diagnostics}
_ -> {:ok, diagnostics}
end
end
defp to_diagnostic(%{module: m, name: n, arity: a, file: file, line: line}, severity) do
%Mix.Task.Compiler.Diagnostic{
compiler_name: "unused",
file: file,
position: line || 0,
message: "unused public function #{inspect(m)}.#{n}/#{a}",
severity: severity,
details: nil
}
end
end

328
lib/mix_unused/analyzer.ex Normal file
View file

@ -0,0 +1,328 @@
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
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})
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)
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)
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

View file

@ -0,0 +1,107 @@
defmodule MixUnused.AnalyzerTest do
use ExUnit.Case, async: false
alias MixUnused.Analyzer
describe "format/1" do
test "renders a friendly message when there are no entries" do
assert Analyzer.format([]) =~ "No unused public functions"
end
test "groups by module, lists each entry, and includes file:line" do
entries = [
%{module: Foo, name: :a, arity: 1, file: "lib/foo.ex", line: 5},
%{module: Foo, name: :b, arity: 0, file: "lib/foo.ex", line: 10},
%{module: Bar, name: :c, arity: 2, file: "lib/bar.ex", line: 7}
]
out = Analyzer.format(entries)
assert out =~ "Unused public functions"
assert out =~ "Foo"
assert out =~ "def a/1"
assert out =~ "def b/0"
assert out =~ "lib/foo.ex:5"
assert out =~ "lib/foo.ex:10"
assert out =~ "Bar"
assert out =~ "def c/2"
assert out =~ "lib/bar.ex:7"
end
test "shows just the file when line is nil" do
entries = [
%{module: Foo, name: :x, arity: 0, file: "lib/foo.ex", line: nil}
]
out = Analyzer.format(entries)
assert out =~ "(lib/foo.ex)"
end
test "shows '?' when both file and line are missing" do
entries = [
%{module: Foo, name: :x, arity: 0, file: nil, line: nil}
]
out = Analyzer.format(entries)
assert out =~ "(?)"
end
end
describe "analyze/1 against the running project" do
test "returns a list of unused-entry maps" do
result = Analyzer.analyze()
assert is_list(result)
# Every entry has the expected shape.
Enum.each(result, fn entry ->
assert is_atom(entry.module)
assert is_atom(entry.name)
assert is_integer(entry.arity)
assert is_nil(entry.file) or is_binary(entry.file)
assert is_nil(entry.line) or is_integer(entry.line)
end)
end
test "result is sorted by module then name then arity" do
result = Analyzer.analyze()
sorted =
Enum.sort_by(result, fn e -> {Atom.to_string(e.module), e.name, e.arity} end)
# The analyzer's own sort key uses the atom directly, but inspect-string
# ordering differs only for funky atoms — so we just confirm the result
# is non-decreasing under our string-based sort.
assert Enum.map(result, & &1.module) ==
Enum.map(sorted, & &1.module)
end
test "exclude option removes whole modules from the output" do
[first | _] = Analyzer.analyze()
assert first
excluded = Analyzer.analyze(exclude: [first.module])
refute Enum.any?(excluded, &(&1.module == first.module))
end
test "exclude option removes specific MFA triples" do
result = Analyzer.analyze()
[first | _] = result
assert first
excluded =
Analyzer.analyze(exclude: [{first.module, first.name, first.arity}])
refute Enum.any?(
excluded,
&(&1.module == first.module and &1.name == first.name and &1.arity == first.arity)
)
end
test "honors :app option — empty list when filtered to a non-existent app" do
result = Analyzer.analyze(app: :no_such_app_for_real)
assert result == []
end
end
end