defmodule Mix.Tasks.Unused do @shortdoc "Find unused functions in the project" @moduledoc """ Finds unused functions in the project by analyzing BEAM bytecode. Compares the set of defined functions against the set of called functions across all compiled `.beam` files in `_build/`. ## Usage mix unused mix unused --verbose mix unused --include-test mix unused --skip-external ## Options * `--verbose` — show every function checked, not just unused ones * `--include-test` — also scan `_build/test/` (scans `_build/dev/` by default) * `--skip-external` — only check intra-project calls, skip external deps ## How it works For each BEAM file in the target build directory, the task reads the `abstract_code` chunk to extract: 1. Every function definition (public and private). 2. Every function call site — local, remote, and qualified Erlang calls. Functions that appear in the definition set but never in the call set are reported as unused. Ignored functions include: * `__info__`, `__struct__`, `__struct__!` — compiler-generated * `init`, `handle_call`, `handle_cast`, `handle_info`, `terminate`, `handle_continue`, `code_change` — OTP callbacks * `child_spec`, `start_link`, `mount`, `handle_params`, `handle_event`, `handle_async`, `render` — Phoenix/LiveView callbacks * Module names ending in `.Test` or `.Fixtures` — test-only modules """ use Mix.Task @dialyzer {:no_opaque, run: 1} # Callbacks that are invoked by the runtime, not by direct function calls. # They look unused to static analysis but are actually called by OTP / # Phoenix / LiveView machinery. @ignored_functions MapSet.new([ # Compiler-generated {:__info__, 1}, {:__struct__, 0}, {:__struct__, 1}, {:__struct__!, 0}, {:__struct__!, 1}, {:__impl__, 1}, {:__behaviour_info__, 1}, {:__schema__, 1}, {:__schema__, 2}, {:__changeset__, 0}, {:__changeset__, 1}, {:__meta__, 1}, {:__repo__, 0}, {:__repo__, 1}, {:__validates__, 0}, {:__validates__, 1}, {:module_info, 0}, {:module_info, 1}, # OTP / GenServer {:init, 0}, {:init, 1}, {:handle_call, 3}, {:handle_cast, 2}, {:handle_info, 2}, {:terminate, 2}, {:handle_continue, 2}, {:code_change, 3}, {:child_spec, 1}, {:start_link, 0}, {:start_link, 1}, {:config_change, 3}, {:format_status, 2}, {:system_continue, 3}, {:system_terminate, 4}, {:system_get_state, 1}, {:system_replace_state, 2}, # Phoenix / LiveView {:mount, 3}, {:handle_params, 3}, {:handle_event, 3}, {:handle_async, 3}, {:render, 1}, {:render, 2}, {:__live__, 0}, {:__live__, 1}, # Plug {:init, 1}, {:call, 2}, # Ecto (changesets, validations — called by Ecto machinery) {:changeset, 2}, {:changeset, 3}, {:validate, 2}, {:validate, 3}, {:prepare_changes, 2}, # Ecto.Repo callbacks (called by Ecto via macros, not by direct calls) {:__adapter__, 0}, {:__adapter__, 1}, {:aggregate, 2}, {:aggregate, 3}, {:aggregate, 4}, {:all, 1}, {:all, 2}, {:all_by, 2}, {:all_by, 3}, {:checked_out?, 0}, {:config, 0}, {:delete, 1}, {:delete, 2}, {:delete!, 1}, {:delete!, 2}, {:delete_all, 1}, {:delete_all, 2}, {:exists?, 1}, {:exists?, 2}, {:get, 1}, {:get, 2}, {:get!, 1}, {:get!, 2}, {:get_by, 1}, {:get_by, 2}, {:get_by!, 1}, {:get_by!, 2}, {:in_transaction?, 0}, {:insert, 1}, {:insert, 2}, {:insert!, 1}, {:insert!, 2}, {:insert_all, 2}, {:insert_all, 3}, {:insert_or_update, 1}, {:insert_or_update, 2}, {:insert_or_update!, 1}, {:insert_or_update!, 2}, {:one, 1}, {:one, 2}, {:one!, 1}, {:one!, 2}, {:preload, 2}, {:preload, 3}, {:put_defaults, 1}, {:query, 1}, {:query!, 1}, {:query_many, 1}, {:query_many!, 1}, {:reload, 1}, {:reload, 2}, {:replica, 0}, {:rollback, 1}, {:stream, 1}, {:stream, 2}, {:transaction, 1}, {:transaction, 2}, {:update, 1}, {:update, 2}, {:update!, 1}, {:update!, 2}, {:update_all, 1}, {:update_all, 2}, {:upsert, 1}, {:upsert, 2}, {:upsert!, 1}, {:upsert!, 2}, {:checkout, 1}, {:checkout, 2}, {:disconnect_all, 1}, {:explain, 2}, {:load, 2}, {:prepare_query, 3}, {:put_dynamic_repo, 1}, {:query, 2}, {:query!, 2}, {:query_many, 2}, {:query_many!, 2}, {:reload!, 1}, {:stop, 0}, {:stop, 1}, {:to_sql, 2}, {:transact, 1}, {:transact, 2}, # Ecto schema timestamps {:timestamps, 0}, {:timestamps, 1}, # Oban Worker pattern — `perform/1`, `new/1`, `timeout/1` {:perform, 1}, {:new, 1}, {:new, 2}, {:timeout, 1}, {:backoff, 1}, # Protocol implementations are called via dispatch {:impl_for, 1}, {:impl_for!, 1}, {:behaviour_info, 1}, # PromEx {:event_metrics, 1}, {:polling_metrics, 1}, {:manual_metrics, 1}, {:dashboards, 1}, # PromEx internal callbacks {:__dashboard_uploader_name__, 0}, {:__grafana_agent_name__, 0}, {:__grafana_client_name__, 0}, {:__grafana_dashboard_uid__, 3}, {:__grafana_folder_uid__, 0}, {:__lifecycle_annotator_name__, 0}, {:__manual_metrics_name__, 0}, {:__metrics_collector_name__, 0}, {:__metrics_server_name__, 0}, {:__otp_app__, 0}, {:__store__, 0}, {:dashboard_assigns, 0}, # Phoenix / LiveView compiler-generated {:__components__, 0}, {:__phoenix_component_verify__, 1}, {:__phoenix_verify_routes__, 1}, {:__mix_recompile__?, 0}, # Phoenix LiveTable {:"MACRO-__using__", 2}, # QRZ XML parser macros {:"MACRO-xmlElement", 1}, {:"MACRO-xmlElement", 3}, {:"MACRO-xmlText", 1}, {:"MACRO-xmlText", 3}, # Phoenix controller actions (called by router dispatch) {:index, 2}, {:show, 2}, {:edit, 2}, {:new, 2}, {:create, 2}, {:update, 2}, {:delete, 2}, {:confirm, 2}, {:home, 2}, {:check, 2}, {:live, 2}, {:skill, 2}, {:catalog, 2}, {:openapi, 2}, {:cells, 2}, {:redirect_contact, 2}, {:redirect_contacts, 2}, {:confirm_email, 2}, {:on_mount, 4} ]) # Modules whose functions should never be reported as unused because # they are entry points (Mix tasks, application callbacks, etc.) @entrypoint_modules MapSet.new([ "Elixir.Microwaveprop.Application", "Elixir.MicrowavepropWeb.Endpoint", "Elixir.MicrowavepropWeb.Router", "Elixir.MicrowavepropWeb.Telemetry", "Elixir.Microwaveprop.Release" ]) # Function names that indicate an entry point (never unused) @entrypoint_functions MapSet.new([ {:start, 2}, {:run, 1}, {:run, 2}, {:evaluate, 2}, {:evaluate, 3}, {:all_features, 0}, {:parse_spot, 1}, {:path_key, 1}, {:merge_spot, 2} ]) @impl true def run(args) do {opts, _} = OptionParser.parse!(args, strict: [ verbose: :boolean, skip_external: :boolean ] ) verbose = Keyword.get(opts, :verbose, false) skip_external = Keyword.get(opts, :skip_external, false) beam_dir = Path.join(Mix.Project.build_path(), "lib/microwaveprop/ebin") if !File.dir?(beam_dir) do Mix.shell().error("BEAM directory not found: #{beam_dir}") Mix.shell().info("Run `mix compile` first.") exit({:shutdown, 1}) end Mix.shell().info("Scanning #{beam_dir}...") beam_files = Path.wildcard(Path.join(beam_dir, "Elixir.Microwaveprop*.beam")) ++ Path.wildcard(Path.join(beam_dir, "Elixir.Mix.Tasks*.beam")) if beam_files == [] do Mix.shell().error("No BEAM files found in #{beam_dir}") exit({:shutdown, 1}) end # Collect definitions and call sites from each module {all_defs, all_calls} = Enum.reduce(beam_files, {MapSet.new(), MapSet.new()}, fn beam_file, {defs_acc, calls_acc} -> {mod_defs, mod_calls} = analyze_beam(beam_file) {MapSet.union(defs_acc, mod_defs), MapSet.union(calls_acc, mod_calls)} end) # Filter out external calls (stdlib, deps) when --skip-external is set all_calls = if skip_external do all_calls |> Enum.filter(fn {mod, _name, _arity} -> String.starts_with?(Atom.to_string(mod), "Elixir.Microwaveprop") end) |> MapSet.new() else all_calls end # Find unused: defined but never called unused = all_defs |> Enum.reject(&function_referenced?(&1, all_calls, skip_external)) |> Enum.sort() if unused == [] do Mix.shell().info("No unused functions found.") else Mix.shell().info("\nUnused functions (#{length(unused)}):\n") # Group by module unused |> Enum.group_by(&elem(&1, 0), &{elem(&1, 1), elem(&1, 2)}) |> Enum.sort() |> Enum.each(&print_module_functions/1) end # Verbose: show all functions checked if verbose do Mix.shell().info("\n--- All defined functions ---") all_defs |> Enum.sort() |> Enum.each(fn {mod, name, arity} -> status = call_status(all_calls, {mod, name, arity}) Mix.shell().info(" #{mod}.#{name}/#{arity} [#{status}]") end) end end defp call_status(all_calls, key), do: if(MapSet.member?(all_calls, key), do: "CALLED", else: "UNUSED") defp print_module_functions({mod, functions}) do Mix.shell().info(" #{mod}") Enum.each(functions, fn {name, arity} -> Mix.shell().info(" #{name}/#{arity}") end) end defp function_referenced?({mod, name, arity}, all_calls, skip_external) do mod_str = Atom.to_string(mod) MapSet.member?(@ignored_functions, {name, arity}) or MapSet.member?(@entrypoint_modules, mod_str) or MapSet.member?(@entrypoint_functions, {name, arity}) or String.ends_with?(mod_str, "Test") or String.ends_with?(mod_str, "Fixtures") or String.contains?(mod_str, ".Impl.") or MapSet.member?(all_calls, {mod, name, arity}) or (skip_external and not String.starts_with?(mod_str, "Elixir.Microwaveprop")) end @spec analyze_beam(String.t()) :: {MapSet.t(), MapSet.t()} defp analyze_beam(beam_file) do module = beam_module(beam_file) case :beam_lib.chunks(String.to_charlist(beam_file), [:abstract_code]) do {:ok, {_mod, chunks}} -> abstract_code = extract_abstract_code(chunks) defs = find_definitions(module, abstract_code) calls = find_calls(module, abstract_code) {defs, calls} _ -> # Binary module or unreadable — skip {MapSet.new(), MapSet.new()} end end defp beam_module(path) do path |> Path.basename() |> Path.rootname() |> String.to_existing_atom() end defp extract_abstract_code(chunks) do case Keyword.get(chunks, :abstract_code) do {:raw_abstract_v1, code} -> code _ -> [] end end # Walk the abstract syntax tree to find function definitions. # Returns MapSet of {module, name, arity} tuples. defp find_definitions(module, abstract_code) do Enum.reduce(abstract_code, MapSet.new(), fn {:function, _line, name, arity, _clauses}, acc -> MapSet.put(acc, {module, name, arity}) _other, acc -> acc end) end # Walk the abstract syntax tree to find function call sites. # Returns MapSet of {target_module, name, arity} tuples. defp find_calls(module, abstract_code) do abstract_code |> Enum.flat_map(&extract_calls/1) |> MapSet.new(fn {:local, name, arity} -> {module, name, arity} {:remote, mod, name, arity} -> {mod, name, arity} end) end # Extract function calls from a single AST form (recursive). defp extract_calls(form) when is_tuple(form) do case form do # Local call: func(args) where func is an atom {:call, _line, {:atom, _line2, name}, args} when is_list(args) -> [{:local, name, length(args)} | extract_calls_in_args(args)] # Remote call: Mod.func(args) where both Mod and func are atoms {:call, _line, {:remote, _line2, {:atom, _line3, mod}, {:atom, _line4, name}}, args} when is_list(args) -> [{:remote, mod, name, length(args)} | extract_calls_in_args(args)] # Block / do-end — recurse into expressions {:block, _line, exprs} when is_list(exprs) -> Enum.flat_map(exprs, &extract_calls/1) # Case / try / receive clause: pattern, guards, body {:clause, _line, _args, _guards, body} when is_list(body) -> Enum.flat_map(body, &extract_calls/1) # Match: lhs = rhs {:match, _line, _lhs, rhs} -> extract_calls(rhs) # Other compound forms — recurse into list and tuple children _ -> form |> Tuple.to_list() |> Enum.flat_map(fn v when is_list(v) -> Enum.flat_map(v, &extract_calls/1) v when is_tuple(v) -> extract_calls(v) _ -> [] end) end end defp extract_calls(_not_a_tuple), do: [] defp extract_calls_in_args(args) do Enum.flat_map(args, fn arg when is_tuple(arg) -> extract_calls(arg) arg when is_list(arg) -> Enum.flat_map(arg, &extract_calls/1) _ -> [] end) end end