aprs.me/test/mix_unused/analyzer_test.exs
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

107 lines
3.2 KiB
Elixir

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