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
|
# 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
|
# in the dialyzer PLT; also the aprs library's return type is inferred incorrectly from
|
||||||
# the compiled beam without full type specs
|
# 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