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//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