132 lines
3.4 KiB
Elixir
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
|