towerops/lib/mix/tasks/unused.ex

132 lines
3.4 KiB
Elixir

defmodule Mix.Tasks.Unused do
@shortdoc "Find unused public functions in the project"
@moduledoc """
Finds public functions that are defined but never called from any other
file in the project.
Inspired by [`mix_unused`](https://github.com/hauleth/mix_unused), this
task is implemented from scratch on top of Erlang's `:xref` cross-
reference analyser (no external dependency).
## Usage
mix unused
# Show only modules under a prefix
mix unused --only Towerops
# Skip modules under a prefix
mix unused --skip ToweropsWeb.GraphQL
# Output as machine-readable JSON
mix unused --format json
## How it works
The task makes sure the project is compiled (via `@requirements`),
then `Towerops.Unused.unused/0` runs `:xref` against
`_build/<env>/lib/towerops/ebin/*.beam` and returns public functions
that nothing else in the analysed set calls.
Several classes of functions are filtered out automatically — see
`Towerops.Unused` for the rules. They include OTP/Phoenix lifecycle
callbacks, Phoenix controller actions, behaviour `@impl` functions,
and ExUnit / fixture / test-case modules.
## Exit code
Exits with 0 always — this is informational, not a failure check. Wrap
in `mix unused | wc -l` to gate CI on a count if you want a hard limit.
"""
use Mix.Task
alias Towerops.Unused
@switches [
only: :string,
skip: :string,
format: :string
]
@requirements ["compile"]
@impl Mix.Task
def run(argv) do
{opts, _argv, _invalid} = OptionParser.parse(argv, strict: @switches)
findings =
Unused.unused()
|> apply_only(opts[:only])
|> apply_skip(opts[:skip])
case opts[:format] do
"json" -> emit_json(findings)
_ -> emit_text(findings)
end
:ok
end
defp apply_only(findings, nil), do: findings
defp apply_only(findings, prefix) do
Enum.filter(findings, fn {mod, _, _, _, _} ->
String.starts_with?(Atom.to_string(mod), "Elixir." <> prefix)
end)
end
defp apply_skip(findings, nil), do: findings
defp apply_skip(findings, prefix) do
Enum.reject(findings, fn {mod, _, _, _, _} ->
String.starts_with?(Atom.to_string(mod), "Elixir." <> prefix)
end)
end
defp emit_text([]) do
Mix.shell().info("No unused public functions found. 🎉")
end
defp emit_text(findings) do
Mix.shell().info("Found #{length(findings)} unused public functions:")
Mix.shell().info("")
findings
|> Enum.group_by(fn {mod, _, _, _, _} -> mod end)
|> Enum.sort_by(fn {mod, _} -> Atom.to_string(mod) end)
|> Enum.each(&print_module/1)
end
defp print_module({module, entries}) do
name =
module
|> Atom.to_string()
|> String.replace_prefix("Elixir.", "")
Mix.shell().info(" #{name}")
Enum.each(entries, fn {_mod, fun, arity, file, line} ->
relative = Path.relative_to_cwd(file)
Mix.shell().info(" #{fun}/#{arity} (#{relative}:#{line})")
end)
Mix.shell().info("")
end
defp emit_json(findings) do
payload =
Enum.map(findings, fn {mod, fun, arity, file, line} ->
%{
module: mod |> Atom.to_string() |> String.replace_prefix("Elixir.", ""),
function: Atom.to_string(fun),
arity: arity,
file: Path.relative_to_cwd(file),
line: line
}
end)
Mix.shell().info(Jason.encode!(payload, pretty: true))
end
end