diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs index e8a651b..792675e 100644 --- a/.dialyzer_ignore.exs +++ b/.dialyzer_ignore.exs @@ -36,5 +36,14 @@ # 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 # 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"} ] diff --git a/lib/mix/tasks/compile/unused.ex b/lib/mix/tasks/compile/unused.ex new file mode 100644 index 0000000..3b6036d --- /dev/null +++ b/lib/mix/tasks/compile/unused.ex @@ -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 diff --git a/lib/mix_unused/analyzer.ex b/lib/mix_unused/analyzer.ex new file mode 100644 index 0000000..a102456 --- /dev/null +++ b/lib/mix_unused/analyzer.ex @@ -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 diff --git a/test/mix_unused/analyzer_test.exs b/test/mix_unused/analyzer_test.exs new file mode 100644 index 0000000..82243f9 --- /dev/null +++ b/test/mix_unused/analyzer_test.exs @@ -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