aprs.me/lib/mix/tasks/compile/unused.ex
Graham McIntire 5184d2ee5e
Cache MixUnused analyzer state to skip repeated BEAM scans in tests
- Split Analyzer.analyze/1 into compute_state/1 (slow, scans all BEAMs)
  and a fast filter step. analyze/1 now accepts :precomputed_state opt.
- Mix.Tasks.Compile.Unused.run/1 picks up precomputed state from process
  dict :mix_unused_extra_analyze_opts when present (test escape hatch).
- AnalyzerTest setup_all computes state once; exclude tests pass it
  through analyze/1, dropping per-test cost from ~700ms to <1ms.
- UnusedTest setup_all does the same — the consolidated severity-args
  test drops from 3.4s to ~5ms.
- Trim further sleeps in HistoricalLoadingTest (100->30ms) and
  MovementTest (50->20ms, receive timeouts 100->30ms).

Total suite: 40.6s -> 37.7s; 2488 tests, 0 failures.
2026-05-09 11:17:08 -05:00

142 lines
3.7 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", [])
# Tests may stash a precomputed Analyzer state in the process dict to
# avoid scanning all BEAM files on every Unused.run/1 call.
extra_analyze_opts = Process.get(:mix_unused_extra_analyze_opts, [])
config
|> filter_options()
|> Keyword.merge(extra_analyze_opts)
|> 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