aprs.me/lib/mix/tasks/compile/unused.ex
Graham McIntire 5fb653bf05
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.
2026-05-08 12:30:04 -05:00

137 lines
3.5 KiB
Elixir

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