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:
parent
039ee83ecb
commit
5fb653bf05
4 changed files with 582 additions and 1 deletions
|
|
@ -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"}
|
||||
]
|
||||
|
|
|
|||
137
lib/mix/tasks/compile/unused.ex
Normal file
137
lib/mix/tasks/compile/unused.ex
Normal 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
328
lib/mix_unused/analyzer.ex
Normal 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
|
||||
107
test/mix_unused/analyzer_test.exs
Normal file
107
test/mix_unused/analyzer_test.exs
Normal 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
|
||||
Loading…
Add table
Reference in a new issue