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", []) # Tests may stash a precomputed Analyzer state in the process dict to # avoid scanning all BEAM files on every Unused.run/1 call. extra_analyze_opts = Process.get(:mix_unused_extra_analyze_opts, []) config |> filter_options() |> Keyword.merge(extra_analyze_opts) |> 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