diff --git a/README.md b/README.md index 165c675..a9c376e 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,40 @@ end Then you just need to run `mix compile` or `mix compile --force` as usual and unused hints will be added to the end of the output. +### Cleaning your project + +The tool keeps track of the calls traced during the compilation. The first time make sure that there is no compiled code: + +```shell +mix clean +``` + +Doing so all the application code is recompiled and the calls are traced properly. + +It is recommended to also perform a clean in the CI when the build does not start from a fresh project, for instance: + +```shell +mix do clean, compile --all-warnings --warnings-as-errors +``` + +Please make sure you don't improperly override the clean task with an alias: + +```elixir +def project do + [ + # ⋯ + aliases: [ + # don't do this: + clean: "deps.unlock --unused", + + # do this: + clean: ["clean", "deps.unlock --unused"], + ], + # ⋯ + ] +end +``` + ### Warning This isn't perfect solution and this will not find dynamic calls in form of: @@ -62,25 +96,64 @@ So this mean that, for example, if you have custom `child_spec/1` definition then `mix unused` can return such function as unused even when you are using that indirectly in your supervisor. +This issue can be mitigated using the `Unreachable` check, explained below. + ### Configuration -You can define used functions by adding `mfa` in `unused: [ignored: [⋯]]` -in your project configuration: +You can configure the tool using the `unused` options in the project configuration. +The following is the default configuration. ```elixir def project do [ # ⋯ unused: [ - ignore: [ - {MyApp.Foo, :child_spec, 1} - ] + checks: [ + # find public functions that could be private + MixUnused.Analyzers.Private, + # find unused public functions + MixUnused.Analyzers.Unused, + # find functions called only recursively + MixUnused.Analyzers.RecursiveOnly + ], + ignore: [], + limit: nil, + paths: nil, + severity: :hint ], # ⋯ ] end ``` +It supports the following options: + +- `checks`: list of analyzer modules to use. + + In alternative to the default set, you can use the [MixUnused.Analyzers.Unreachable](`MixUnused.Analyzers.Unreachable`) check (see the specific [guide](guides/unreachable-analyzer.md)). + +- `ignore`: list of ignored functions, example: + + ```elixir + [ + {:_, ~r/^__.+__\??$/, :_}, + {~r/^MyAppWeb\..*Controller/, :_, 2}, + {MyApp.Test, :foo, 1..2} + ] + ``` + + See the [Mix.Tasks.Compile.Unused](`Mix.Tasks.Compile.Unused`) task for further details. + +- `limit`: max number of results to report (available also as the command option `--limit`). + +- `paths`: report only functions defined in such paths. + + Useful to restrict the reported functions only to the functions defined in specific paths + (i.e. set `paths: ["lib"]` to ignore functions defined in the `tests` folder). + +- `severity`: severity of the reported messages. + Allowed levels are `:hint`, `:information`, `:warning`, and `:error`. + ## Copyright and License Copyright © 2021 by Łukasz Niemier diff --git a/lib/mix/tasks/compile.unused.ex b/lib/mix/tasks/compile.unused.ex index 1775c77..0d666b1 100644 --- a/lib/mix/tasks/compile.unused.ex +++ b/lib/mix/tasks/compile.unused.ex @@ -142,7 +142,9 @@ defmodule Mix.Tasks.Compile.Unused do config.checks |> MixUnused.Analyze.analyze(data, all_functions, config) + |> filter_files_in_paths(config.paths) |> Enum.sort_by(&{&1.file, &1.position, &1.details.mfa}) + |> limit_results(config.limit) |> tap_all(&print_diagnostic/1) |> case do [] -> @@ -181,6 +183,18 @@ defmodule Mix.Tasks.Compile.Unused do defp normalise_cache(map) when is_map(map), do: {:v0, map} defp normalise_cache(_), do: %{} + defp filter_files_in_paths(diags, nil), do: diags + + defp filter_files_in_paths(diags, paths) do + Enum.filter(diags, fn %Diagnostic{file: file} -> + [root | _] = file |> Path.relative_to_cwd() |> Path.split() + root in paths + end) + end + + defp limit_results(diags, nil), do: diags + defp limit_results(diags, limit), do: Enum.take(diags, limit) + defp print_diagnostic(%Diagnostic{details: %{mfa: {_, :__struct__, 1}}}), do: nil diff --git a/lib/mix_unused/analyze.ex b/lib/mix_unused/analyze.ex index 1448f3b..b4b324f 100644 --- a/lib/mix_unused/analyze.ex +++ b/lib/mix_unused/analyze.ex @@ -3,21 +3,34 @@ defmodule MixUnused.Analyze do alias Mix.Task.Compiler.Diagnostic + alias MixUnused.Config alias MixUnused.Exports alias MixUnused.Tracer + @type config :: map() + @type analyzer :: module() | {module(), config()} + @callback message() :: String.t() - @callback analyze(Tracer.data(), Exports.t()) :: Exports.t() + @callback analyze(Tracer.data(), Exports.t(), config()) :: Exports.t() - @spec analyze(module() | [module()], Tracer.data(), Exports.t(), map()) :: - Diagnostic.t() + @spec analyze( + analyzer() | [analyzer()], + Tracer.data(), + Exports.t(), + Config.t() + ) :: + [Diagnostic.t()] def analyze(analyzers, data, all_functions, config) when is_list(analyzers), do: Enum.flat_map(analyzers, &analyze(&1, data, all_functions, config)) - def analyze(analyzer, data, all_functions, config) when is_atom(analyzer) do + def analyze(analyzer, data, all_functions, config) when is_atom(analyzer), + do: analyze({analyzer, %{}}, data, all_functions, config) + + def analyze({analyzer, analyzer_config}, data, all_functions, config) do message = analyzer.message() - for {mfa, meta} = desc <- analyzer.analyze(data, all_functions) do + for {mfa, meta} = desc <- + analyzer.analyze(data, all_functions, analyzer_config) do %Diagnostic{ compiler_name: "unused", message: "#{signature(desc)} #{message}", diff --git a/lib/mix_unused/analyzers/private.ex b/lib/mix_unused/analyzers/private.ex index 00b88cd..2518265 100644 --- a/lib/mix_unused/analyzers/private.ex +++ b/lib/mix_unused/analyzers/private.ex @@ -7,7 +7,7 @@ defmodule MixUnused.Analyzers.Private do def message, do: "should be private (is not used outside defining module)" @impl true - def analyze(data, all_functions) do + def analyze(data, all_functions, _config) do data = Map.new(data) for {{_, f, _} = mfa, meta} = desc <- all_functions, diff --git a/lib/mix_unused/analyzers/recursive_only.ex b/lib/mix_unused/analyzers/recursive_only.ex index 1fdecaa..5e1706e 100644 --- a/lib/mix_unused/analyzers/recursive_only.ex +++ b/lib/mix_unused/analyzers/recursive_only.ex @@ -7,7 +7,7 @@ defmodule MixUnused.Analyzers.RecursiveOnly do def message, do: "is called only recursively" @impl true - def analyze(data, all_functions) do + def analyze(data, all_functions, _config) do non_rec_calls = for {mod, calls} <- data, {{m, f, a} = mfa, %{caller: {call_f, call_a}}} <- calls, diff --git a/lib/mix_unused/analyzers/unused.ex b/lib/mix_unused/analyzers/unused.ex index 9929196..10c4dec 100644 --- a/lib/mix_unused/analyzers/unused.ex +++ b/lib/mix_unused/analyzers/unused.ex @@ -1,22 +1,20 @@ defmodule MixUnused.Analyzers.Unused do @moduledoc false + alias MixUnused.Analyzers.Calls + alias MixUnused.Meta + @behaviour MixUnused.Analyze @impl true def message, do: "is unused" @impl true - def analyze(data, possibly_uncalled) do - graph = Graph.new(type: :directed) - - graph = - for {m, calls} <- data, - {mfa, %{caller: {f, a}}} <- calls, - reduce: graph do - acc -> - Graph.add_edge(acc, {m, f, a}, mfa) - end + def analyze(data, exports, _config) do + possibly_uncalled = + Map.filter(exports, &match?({_mfa, %Meta{callback: false}}, &1)) + + graph = Calls.calls_graph(data, possibly_uncalled) called = Graph.Reducers.Dfs.reduce(graph, MapSet.new(), fn v, acc -> diff --git a/lib/mix_unused/calls.ex b/lib/mix_unused/calls.ex new file mode 100644 index 0000000..7ffa838 --- /dev/null +++ b/lib/mix_unused/calls.ex @@ -0,0 +1,94 @@ +defmodule MixUnused.Analyzers.Calls do + @moduledoc false + + alias MixUnused.Debug + alias MixUnused.Exports + alias MixUnused.Meta + alias MixUnused.Tracer + + @type t :: Graph.t() + + @doc """ + Creates a graph where each node is a function and an edge from `f` to `g` + means that the function `f` calls `g`. + """ + @spec calls_graph(Tracer.data(), Exports.t()) :: t() + def calls_graph(data, exports) do + Graph.new(type: :directed) + |> add_calls(data) + |> add_calls_from_default_functions(exports) + |> Debug.debug(&log_graph/1) + end + + defp add_calls(graph, data) do + for {m, calls} <- data, + {mfa, %{caller: {f, a}}} <- calls, + reduce: graph do + acc -> Graph.add_edge(acc, {m, f, a}, mfa) + end + end + + defp add_calls_from_default_functions(graph, exports) do + # A function with default arguments is splitted at compile-time in multiple functions + #  with different arities. + #  The main function is indirectly called when a function with default arguments is called, + #  so the graph should contain an edge for each generated function (from the generated + #  function to the main one). + for {{m, f, a} = mfa, %Meta{doc_meta: meta}} <- exports, + defaults = Map.get(meta, :defaults, 0), + defaults > 0, + arity <- (a - defaults)..(a - 1), + reduce: graph do + graph -> Graph.add_edge(graph, {m, f, arity}, mfa) + end + end + + @doc """ + Gets all the exported functions called from some module at compile-time. + """ + @spec called_at_compile_time(Tracer.data(), Exports.t()) :: [mfa()] + def called_at_compile_time(data, exports) do + for {_m, calls} <- data, + {mfa, %{caller: nil}} <- calls, + Map.has_key?(exports, mfa), + into: MapSet.new(), + do: mfa + end + + defp log_graph(graph) do + write_edgelist(graph) + write_binary(graph) + end + + defp write_edgelist(graph) do + {:ok, content} = Graph.to_edgelist(graph) + path = Path.join(Mix.Project.manifest_path(), "graph.txt") + File.write!(path, content) + + Mix.shell().info([ + IO.ANSI.yellow_background(), + "Serialized edgelist to #{path}", + :reset + ]) + end + + defp write_binary(graph) do + content = :erlang.term_to_binary(graph) + path = Path.join(Mix.Project.manifest_path(), "graph.bin") + File.write!(path, content) + + Mix.shell().info([ + IO.ANSI.yellow_background(), + "Serialized graph to #{path}", + IO.ANSI.reset(), + IO.ANSI.light_black(), + "\n\nTo use it from iex:\n", + ~s{ + Mix.install([libgraph: ">= 0.0.0"]) + graph = "#{path}" |> File.read!() |> :erlang.binary_to_term() + Graph.info(graph) + }, + IO.ANSI.reset() + ]) + end +end diff --git a/lib/mix_unused/config.ex b/lib/mix_unused/config.ex index 1ec3468..72288f3 100644 --- a/lib/mix_unused/config.ex +++ b/lib/mix_unused/config.ex @@ -1,20 +1,33 @@ defmodule MixUnused.Config do @moduledoc false + @type t :: %__MODULE__{ + checks: [MixUnused.Analyze.analyzer()], + ignore: [mfa()], + limit: integer() | nil, + paths: [String.t()] | nil, + severity: :hint | :information | :warning | :error, + warnings_as_errors: boolean() + } + defstruct checks: [ MixUnused.Analyzers.Private, MixUnused.Analyzers.Unused, MixUnused.Analyzers.RecursiveOnly ], ignore: [], + limit: nil, + paths: nil, severity: :hint, warnings_as_errors: false @options [ + limit: :integer, severity: :string, warnings_as_errors: :boolean ] + @spec build([binary], nil | maybe_improper_list | map) :: MixUnused.Config.t() def build(argv, config) do {opts, _rest, _other} = OptionParser.parse(argv, strict: @options) @@ -25,13 +38,17 @@ defmodule MixUnused.Config do defp extract_config(%__MODULE__{} = config, mix_config) do config + |> maybe_set(:checks, mix_config[:checks]) |> maybe_set(:ignore, mix_config[:ignore]) + |> maybe_set(:limit, mix_config[:limit]) + |> maybe_set(:paths, mix_config[:paths]) |> maybe_set(:severity, mix_config[:severity]) |> maybe_set(:warnings_as_errors, mix_config[:warnings_as_errors]) end defp extract_opts(%__MODULE__{} = config, opts) do config + |> maybe_set(:limit, opts[:limit]) |> maybe_set(:severity, opts[:severity], &severity/1) |> maybe_set(:warnings_as_errors, opts[:warnings_as_errors]) end diff --git a/lib/mix_unused/debug.ex b/lib/mix_unused/debug.ex new file mode 100644 index 0000000..18fffab --- /dev/null +++ b/lib/mix_unused/debug.ex @@ -0,0 +1,11 @@ +defmodule MixUnused.Debug do + @moduledoc false + + @spec debug(v, (v -> term)) :: v when v: var + def debug(value, fun) do + if debug?(), do: fun.(value) + value + end + + defp debug?, do: System.get_env("MIX_UNUSED_DEBUG") == "true" +end diff --git a/lib/mix_unused/exports.ex b/lib/mix_unused/exports.ex index 33a2e4e..c60e0ca 100644 --- a/lib/mix_unused/exports.ex +++ b/lib/mix_unused/exports.ex @@ -1,9 +1,13 @@ defmodule MixUnused.Exports do - @moduledoc false + @moduledoc """ + Detects the functions exported by the application. + + In Elixir slang, an "exported" function is called "public" function. + """ alias MixUnused.Meta - @type t() :: %{mfa() => Meta.t()} | [{mfa(), Meta.t()}] + @type t() :: %{mfa() => Meta.t()} @types ~w[function macro]a @@ -23,8 +27,8 @@ defmodule MixUnused.Exports do |> Map.new() end - @spec fetch(module()) :: t() - def fetch(module) do + @spec fetch(module()) :: [{mfa(), Meta.t()}] + defp fetch(module) do # Check exported functions without loading modules as this could cause # unexpected behaviours in case of `on_load` callbacks with path when is_list(path) <- :code.which(module), @@ -37,14 +41,24 @@ defmodule MixUnused.Exports do source = data[:compile_info] |> Keyword.get(:source, "nofile") |> to_string() + user_functions = user_functions(docs) + for {{type, name, arity}, anno, [sig | _], _doc, meta} <- docs, type in @types, - {name, arity} not in @ignored, - {name, arity} not in callbacks do + {name, arity} not in @ignored do line = :erl_anno.line(anno) + callback = {name, arity} in callbacks + generated = {name, arity} not in user_functions {{module, name, arity}, - %Meta{signature: sig, file: source, line: line, doc_meta: meta}} + %Meta{ + signature: sig, + file: source, + line: line, + doc_meta: meta, + callback: callback, + generated: generated + }} end else _ -> [] @@ -77,4 +91,14 @@ defmodule MixUnused.Exports do [] end end + + defp user_functions(docs) do + # Hack: guess functions that are not generated at compile-time by + # checking if there are multiple functions defined at the same position. + docs + |> Enum.group_by(fn {_item, anno, _sig, _doc, _meta} -> anno end) + |> Enum.filter(&match?({_, [_]}, &1)) + |> Enum.flat_map(fn {_, items} -> items end) + |> Enum.map(fn {{_, f, a}, _anno, _sig, _doc, _meta} -> {f, a} end) + end end diff --git a/lib/mix_unused/filter.ex b/lib/mix_unused/filter.ex index 8ec8612..33fcc8e 100644 --- a/lib/mix_unused/filter.ex +++ b/lib/mix_unused/filter.ex @@ -24,23 +24,35 @@ defmodule MixUnused.Filter do @spec reject_matching(exports :: Exports.t(), patterns :: [pattern()]) :: Exports.t() def reject_matching(exports, patterns) do - filters = - Enum.map(patterns, fn - {_m, _f, _a} = entry -> entry - {m, f} -> {m, f, :_} - {m} -> {m, :_, :_} - m when is_atom(m) -> {m, :_, :_} - %Regex{} = m -> {m, :_, :_} - cb when is_function(cb) -> cb - end) - - Enum.reject(exports, fn {func, meta} -> - Enum.any?(filters, &mfa_match?(&1, func, meta)) + Map.reject(exports, matcher(patterns)) + end + + @spec filter_matching(exports :: Exports.t(), patterns :: [pattern()]) :: + Exports.t() + def filter_matching(exports, patterns) do + Map.filter(exports, matcher(patterns)) + end + + @spec matcher(patterns :: [pattern()]) :: ({mfa(), Meta.t()} -> boolean()) + defp matcher(patterns) do + filters = normalize_filter_patterns(patterns) + + fn {mfa, meta} -> Enum.any?(filters, &mfa_match?(&1, mfa, meta)) end + end + + @spec normalize_filter_patterns(patterns :: [pattern()]) :: [pattern()] + defp normalize_filter_patterns(patterns) do + Enum.map(patterns, fn + {_m, _f, _a} = entry -> entry + {m, f} -> {m, f, :_} + {m} -> {m, :_, :_} + m when is_atom(m) -> {m, :_, :_} + %Regex{} = m -> {m, :_, :_} + cb when is_function(cb) -> cb end) - |> Map.new() end - @spec mfa_match?(mfa(), pattern(), Meta.t()) :: boolean() + @spec mfa_match?(pattern(), mfa(), Meta.t()) :: boolean() defp mfa_match?({pmod, pname, parity}, {fmod, fname, farity}, _meta) do match?(pmod, fmod) and match?(pname, fname) and arity_match?(parity, farity) end diff --git a/lib/mix_unused/meta.ex b/lib/mix_unused/meta.ex index 8d7432a..b44f161 100644 --- a/lib/mix_unused/meta.ex +++ b/lib/mix_unused/meta.ex @@ -12,13 +12,22 @@ defmodule MixUnused.Meta do (currently, it can point to the line where documentation is defined, not exactly to function head). - `:doc_meta` - documentation metadata of the given function. + - `:callback` - true if the function is a callback, false otherwise. + - `:generated` - true if the function is generated, false if this condition is not determinated. """ @type t() :: %__MODULE__{ signature: String.t(), file: String.t(), line: non_neg_integer(), - doc_meta: map() + doc_meta: map(), + callback: boolean(), + generated: boolean() } - defstruct signature: nil, file: "nofile", line: 1, doc_meta: %{} + defstruct signature: nil, + file: "nofile", + line: 1, + doc_meta: %{}, + callback: false, + generated: false end diff --git a/mix.exs b/mix.exs index d408c92..821245d 100644 --- a/mix.exs +++ b/mix.exs @@ -9,7 +9,7 @@ defmodule MixUnused.MixProject do app: :mix_unused, description: "Mix compiler tracer for detecting unused public functions", version: @version, - elixir: "~> 1.10", + elixir: "~> 1.13", package: [ licenses: ~w[MIT], links: %{ @@ -19,17 +19,32 @@ defmodule MixUnused.MixProject do } ], deps: [ - {:libgraph, ">= 0.0.0"}, + {:covertool, "~> 2.0", only: :test}, {:credo, ">= 0.0.0", only: :dev, runtime: false}, - {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, {:dialyxir, "~> 1.0", only: :dev, runtime: false}, - {:stream_data, ">= 0.0.0", only: [:test, :dev]}, - {:covertool, "~> 2.0", only: :test} + {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, + {:libgraph, ">= 0.0.0"}, + {:mock, "~> 0.3.7", only: :test}, + {:stream_data, ">= 0.0.0", only: [:test, :dev]} ], docs: [ extras: [ + "README.md": [title: "Overview"], "CHANGELOG.md": [], - LICENSE: [title: "License"] + LICENSE: [title: "License"], + "guides/unreachable-analyzer.md": [ + title: "Using the Unreachable analyzer" + ] + ], + groups_for_extras: [ + Guides: ~r"guides/" + ], + groups_for_modules: [ + "Usages discovery": ~r"MixUnused.Analyzers.Unreachable.Usages.\w+$" + ], + nest_modules_by_prefix: [ + MixUnused.Analyzers, + MixUnused.Analyzers.Unreachable.Usages ], main: "Mix.Tasks.Compile.Unused", source_url: @source_url, diff --git a/mix.lock b/mix.lock index ea1914a..45c2571 100644 --- a/mix.lock +++ b/mix.lock @@ -12,6 +12,8 @@ "makeup": {:hex, :makeup, "1.0.5", "d5a830bc42c9800ce07dd97fa94669dfb93d3bf5fcf6ea7a0c67b2e0e4a7f26c", [:mix], [{:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cfa158c02d3f5c0c665d0af11512fed3fba0144cf1aadee0f2ce17747fba2ca9"}, "makeup_elixir": {:hex, :makeup_elixir, "0.15.2", "dc72dfe17eb240552857465cc00cce390960d9a0c055c4ccd38b70629227e97c", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fd23ae48d09b32eff49d4ced2b43c9f086d402ee4fd4fcb2d7fad97fa8823e75"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, + "meck": {:hex, :meck, "0.9.2", "85ccbab053f1db86c7ca240e9fc718170ee5bda03810a6292b5306bf31bae5f5", [:rebar3], [], "hexpm", "81344f561357dc40a8344afa53767c32669153355b626ea9fcbc8da6b3045826"}, + "mock": {:hex, :mock, "0.3.7", "75b3bbf1466d7e486ea2052a73c6e062c6256fb429d6797999ab02fa32f29e03", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "4da49a4609e41fd99b7836945c26f373623ea968cfb6282742bcb94440cf7e5c"}, "nimble_parsec": {:hex, :nimble_parsec, "1.2.1", "264fc6864936b59fedb3ceb89998c64e9bb91945faf1eb115d349b96913cc2ef", [:mix], [], "hexpm", "23c31d0ec38c97bf9adde35bc91bc8e1181ea5202881f48a192f4aa2d2cf4d59"}, "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"}, } diff --git a/test/fixtures/clean/lib/simple_server.ex b/test/fixtures/clean/lib/simple_server.ex index af66d8a..df6f52e 100644 --- a/test/fixtures/clean/lib/simple_server.ex +++ b/test/fixtures/clean/lib/simple_server.ex @@ -5,7 +5,8 @@ defmodule SimpleServer do def init(_), do: {:ok, []} - def handle_call(%SimpleStruct{}, _ref, state), do: {:reply, :ok, state} + def handle_call(%SimpleStruct{} = s, _ref, state), + do: {:reply, :ok, SimpleStruct.foo(s, nil) ++ state} def handle_cast(_msg, state), do: {:noreply, state} end diff --git a/test/fixtures/clean/lib/simple_struct.ex b/test/fixtures/clean/lib/simple_struct.ex index 93250a8..87f4418 100644 --- a/test/fixtures/clean/lib/simple_struct.ex +++ b/test/fixtures/clean/lib/simple_struct.ex @@ -1,3 +1,5 @@ defmodule SimpleStruct do defstruct [:foo] + + def foo(%__MODULE__{foo: foo}, _default_arg \\ nil), do: foo end diff --git a/test/fixtures/clean/mix.exs b/test/fixtures/clean/mix.exs index e79f83e..4d5a12f 100644 --- a/test/fixtures/clean/mix.exs +++ b/test/fixtures/clean/mix.exs @@ -4,7 +4,7 @@ defmodule MixUnused.Fixtures.CleanProject do def project do [ app: :clean, - compilers: [:unused | Mix.compilers()], + compilers: [:unused] ++ Mix.compilers(), version: "0.0.0" ] end diff --git a/test/fixtures/two_mods/mix.exs b/test/fixtures/two_mods/mix.exs index 9340968..4b4108d 100644 --- a/test/fixtures/two_mods/mix.exs +++ b/test/fixtures/two_mods/mix.exs @@ -4,7 +4,7 @@ defmodule MixUnused.Fixtures.TwoModsProject do def project do [ app: :two_mods, - compilers: [:unused | Mix.compilers()], + compilers: [:unused] ++ Mix.compilers(), version: "0.0.0", unused: [ ignore: [ diff --git a/test/fixtures/umbrella/apps/a/mix.exs b/test/fixtures/umbrella/apps/a/mix.exs index 8360020..2d4099b 100644 --- a/test/fixtures/umbrella/apps/a/mix.exs +++ b/test/fixtures/umbrella/apps/a/mix.exs @@ -4,7 +4,7 @@ defmodule MixUnused.Fixtures.Umbrella.AProject do def project do [ app: :a, - compilers: [:unused | Mix.compilers()], + compilers: [:unused] ++ Mix.compilers(), version: "0.0.0" ] end diff --git a/test/fixtures/umbrella/apps/b/mix.exs b/test/fixtures/umbrella/apps/b/mix.exs index e72867f..5383697 100644 --- a/test/fixtures/umbrella/apps/b/mix.exs +++ b/test/fixtures/umbrella/apps/b/mix.exs @@ -4,7 +4,7 @@ defmodule MixUnused.Fixtures.Umbrella.BProject do def project do [ app: :b, - compilers: [:unused | Mix.compilers()], + compilers: [:unused] ++ Mix.compilers(), version: "0.0.0", deps: [{:a, in_umbrella: true}] ] diff --git a/test/fixtures/unclean/mix.exs b/test/fixtures/unclean/mix.exs index 075adab..55f3081 100644 --- a/test/fixtures/unclean/mix.exs +++ b/test/fixtures/unclean/mix.exs @@ -4,7 +4,7 @@ defmodule MixUnused.Fixtures.UnleanProject do def project do [ app: :unclean, - compilers: [:unused | Mix.compilers()], + compilers: [:unused] ++ Mix.compilers(), version: "0.0.0", unused: [ ignore: [ diff --git a/test/mix/tasks/compile.unused_test.exs b/test/mix/tasks/compile.unused_test.exs index c7a4eda..0c74f71 100644 --- a/test/mix/tasks/compile.unused_test.exs +++ b/test/mix/tasks/compile.unused_test.exs @@ -3,7 +3,7 @@ defmodule Mix.Tasks.Compile.UnusedTest do import ExUnit.CaptureIO - alias MixUnused.Analyzers.{Private, Unused, RecursiveOnly} + alias MixUnused.Analyzers.{Private, Unreachable, Unused, RecursiveOnly} describe "umbrella" do test "simple file" do @@ -78,6 +78,157 @@ defmodule Mix.Tasks.Compile.UnusedTest do end end + describe "using unreachable analyzer" do + test "unused functions are reported" do + in_fixture("unreachable", fn -> + assert {{:ok, diagnostics}, output} = run(:unreachable, "compile") + assert 4 == length(diagnostics), output + end) + end + + test "used structs are not reported" do + in_fixture("unreachable", fn -> + assert {{:ok, diagnostics}, output} = run(:unreachable, "compile") + + refute find_diagnostics_for( + diagnostics, + {SimpleStruct, :__struct__, 0}, + Unreachable + ), + output + end) + end + + test "public functions called with default arguments are not reported" do + in_fixture("unreachable", fn -> + assert {{:ok, diagnostics}, output} = run(:unreachable, "compile") + + refute find_diagnostics_for( + diagnostics, + {SimpleStruct, :foo, 2}, + Unreachable + ), + output + end) + end + + test "top-level unused functions are reported" do + in_fixture("unreachable", fn -> + assert {{:ok, diagnostics}, output} = run(:unreachable, "compile") + + assert find_diagnostics_for( + diagnostics, + {SimpleModule, :public_unused, 0}, + Unreachable + ), + output + end) + end + + test "functions called transitively from used functions are not reported by default" do + in_fixture("unreachable", fn -> + assert {{:ok, diagnostics}, output} = run(:unreachable, "compile") + + refute find_diagnostics_for( + diagnostics, + {SimpleModule, :use_foo, 1}, + Unreachable + ), + output + end) + end + + test "functions called transitively from unused public functions are not reported by default" do + in_fixture("unreachable", fn -> + assert {{:ok, diagnostics}, output} = run(:unreachable, "compile") + + refute find_diagnostics_for( + diagnostics, + {SimpleModule, :used_from_unused, 0}, + Unreachable + ), + output + end) + end + + test "functions called transitively from unused private functions are not reported by default" do + in_fixture("unreachable", fn -> + assert {{:ok, diagnostics}, output} = run(:unreachable, "compile") + + refute find_diagnostics_for( + diagnostics, + {SimpleModule, :public_used_by_unused_private, 0}, + Unreachable + ), + output + end) + end + + test "functions declared as used are not reported" do + in_fixture("unreachable", fn -> + assert {{:ok, diagnostics}, output} = run(:unreachable, "compile") + + refute find_diagnostics_for( + diagnostics, + {SimpleServer, :init, 1}, + Unreachable + ), + output + end) + end + + test "generated functions are not reported" do + in_fixture("unreachable", fn -> + assert {{:ok, diagnostics}, output} = run(:unreachable, "compile") + + refute find_diagnostics_for( + diagnostics, + {SimpleModule, :g, 0}, + Unreachable + ), + output + end) + end + + test "functions evaluated at compile-time are not reported" do + in_fixture("unreachable", fn -> + assert {{:ok, diagnostics}, output} = run(:unreachable, "compile") + + refute find_diagnostics_for( + diagnostics, + {Constants, :answer, 0}, + Unreachable + ), + output + end) + end + + test "unused structs are reported" do + in_fixture("unreachable", fn -> + assert {{:ok, diagnostics}, output} = run(:unreachable, "compile") + + assert find_diagnostics_for( + diagnostics, + {UnusedStruct, :__struct__, 0}, + Unreachable + ), + output + end) + end + + test "unused private functions are reported by Elixir" do + in_fixture("unreachable", fn -> + assert {{:ok, diagnostics}, output} = run(:unreachable, "compile") + + diagnostics = Enum.filter(diagnostics, &(&1.compiler_name == "Elixir")) + + assert [%{message: "function private_unused/0 is unused"}] = + diagnostics, + output + end) + end + end + describe "unclean" do test "ignored function is not reported" do in_fixture("unclean", fn -> @@ -212,7 +363,8 @@ defmodule Mix.Tasks.Compile.UnusedTest do defp find_diagnostics_for(diagnostics, mfa, analyzer) do Enum.find( diagnostics, - &(&1.details.mfa == mfa and &1.details.analyzer == analyzer) + &(&1.compiler_name == "unused" and &1.details.mfa == mfa and + &1.details.analyzer == analyzer) ) end end diff --git a/test/mix_unused/analyzers/private_test.exs b/test/mix_unused/analyzers/private_test.exs index fd50637..db749ac 100644 --- a/test/mix_unused/analyzers/private_test.exs +++ b/test/mix_unused/analyzers/private_test.exs @@ -8,14 +8,14 @@ defmodule MixUnused.Analyzers.PrivateTest do doctest @subject test "no functions" do - assert %{} == @subject.analyze(%{}, []) + assert %{} == @subject.analyze(%{}, [], %{}) end test "called externally" do function = {Foo, :a, 1} calls = %{Bar => [{function, %{}}]} - assert %{} == @subject.analyze(calls, [{function, %Meta{}}]) + assert %{} == @subject.analyze(calls, [{function, %Meta{}}], %{}) end test "called internally and externally" do @@ -26,28 +26,33 @@ defmodule MixUnused.Analyzers.PrivateTest do Bar => [{function, %{}}] } - assert %{} == @subject.analyze(calls, [{function, %Meta{}}]) + assert %{} == @subject.analyze(calls, [{function, %Meta{}}], %{}) end test "called only internally" do function = {Foo, :a, 1} calls = %{Foo => [{function, %{}}]} - assert %{^function => _} = @subject.analyze(calls, [{function, %Meta{}}]) + assert %{^function => _} = + @subject.analyze(calls, [{function, %Meta{}}], %{}) end test "not called at all" do function = {Foo, :a, 1} - assert %{} == @subject.analyze(%{}, [{function, %Meta{}}]) + assert %{} == @subject.analyze(%{}, [{function, %Meta{}}], %{}) end test "functions with metadata `:internal` set to true are ignored" do function = {Foo, :a, 1} assert %{} == - @subject.analyze(%{}, [ - {function, %Meta{doc_meta: %{internal: true}}} - ]) + @subject.analyze( + %{}, + [ + {function, %Meta{doc_meta: %{internal: true}}} + ], + %{} + ) end end diff --git a/test/mix_unused/analyzers/recursive_only_test.exs b/test/mix_unused/analyzers/recursive_only_test.exs index d503d51..45f5841 100644 --- a/test/mix_unused/analyzers/recursive_only_test.exs +++ b/test/mix_unused/analyzers/recursive_only_test.exs @@ -8,14 +8,15 @@ defmodule MixUnused.Analyzers.RecursiveOnlyTest do doctest @subject test "no functions" do - assert %{} == @subject.analyze(%{}, []) + assert %{} == @subject.analyze(%{}, [], %{}) end test "called only recursively" do function = {Foo, :a, 1} calls = %{Foo => [{function, %{caller: {:a, 1}}}]} - assert %{^function => _} = @subject.analyze(calls, [{function, %Meta{}}]) + assert %{^function => _} = + @subject.analyze(calls, [{function, %Meta{}}], %{}) end test "called by other function within the same module" do @@ -28,6 +29,6 @@ defmodule MixUnused.Analyzers.RecursiveOnlyTest do ] } - assert %{} == @subject.analyze(calls, [{function, %Meta{}}]) + assert %{} == @subject.analyze(calls, [{function, %Meta{}}], %{}) end end diff --git a/test/mix_unused/analyzers/unused_test.exs b/test/mix_unused/analyzers/unused_test.exs index dc3abdd..206e4cf 100644 --- a/test/mix_unused/analyzers/unused_test.exs +++ b/test/mix_unused/analyzers/unused_test.exs @@ -8,14 +8,14 @@ defmodule MixUnused.Analyzers.UnusedTest do doctest @subject test "no functions" do - assert %{} == @subject.analyze(%{}, []) + assert %{} == @subject.analyze(%{}, %{}, %{}) end test "called externally" do function = {Foo, :a, 1} calls = %{Bar => [{function, %{caller: {:b, 1}}}]} - assert %{} == @subject.analyze(calls, [{function, %Meta{}}]) + assert %{} == @subject.analyze(calls, %{function => %Meta{}}, %{}) end test "called internally and externally" do @@ -26,29 +26,32 @@ defmodule MixUnused.Analyzers.UnusedTest do Bar => [{function, %{caller: {:b, 1}}}] } - assert %{} == @subject.analyze(calls, [{function, %Meta{}}]) + assert %{} == @subject.analyze(calls, %{function => %Meta{}}, %{}) end test "called only internally" do function = {Foo, :a, 1} calls = %{Foo => [{function, %{caller: {:b, 1}}}]} - assert %{} == @subject.analyze(calls, [{function, %Meta{}}]) + assert %{} == @subject.analyze(calls, %{function => %Meta{}}, %{}) end test "not called at all" do function = {Foo, :a, 1} - assert %{^function => _} = @subject.analyze(%{}, [{function, %Meta{}}]) + assert %{^function => _} = + @subject.analyze(%{}, %{function => %Meta{}}, %{}) end test "functions with metadata `:export` set to true are ignored" do function = {Foo, :a, 1} assert %{} == - @subject.analyze(%{}, [ - {function, %Meta{doc_meta: %{export: true}}} - ]) + @subject.analyze( + %{}, + %{function => %Meta{doc_meta: %{export: true}}}, + %{} + ) end test "transitive functions are reported" do @@ -56,16 +59,25 @@ defmodule MixUnused.Analyzers.UnusedTest do function_b = {Foo, :b, 1} calls = %{ - Foo => [{function_b, %{function: {:a, 1}}}] + Foo => [{function_b, %{caller: {:a, 1}}}] } assert %{ ^function_a => _, ^function_b => _ } = - @subject.analyze(calls, [ - {function_a, %Meta{}}, - {function_b, %Meta{}} - ]) + @subject.analyze( + calls, + %{function_a => %Meta{}, function_b => %Meta{}}, + %{} + ) + end + + test "functions called with default arguments are not reported" do + function = {Foo, :a, 1} + functions = %{function => %Meta{doc_meta: %{defaults: 1}}} + calls = %{Foo => [{{Foo, :a, 0}, %{caller: {:b, 1}}}]} + + assert %{} == @subject.analyze(calls, functions, %{}) end end