From b9abb6e066afc3ab52630c98660d0e091f6b9c57 Mon Sep 17 00:00:00 2001 From: Mauro Rocchi <240925+enerdgumen@users.noreply.github.com> Date: Fri, 11 Nov 2022 16:44:15 +0100 Subject: [PATCH] ft: add an alternative analyzer to detect unused code Co-authored-by: Thomas Alderighi --- README.md | 83 ++++++++- guides/unreachable-analyzer.md | 60 +++++++ lib/mix/tasks/compile.unused.ex | 14 ++ lib/mix_unused/analyze.ex | 23 ++- lib/mix_unused/analyzers/private.ex | 2 +- lib/mix_unused/analyzers/recursive_only.ex | 2 +- lib/mix_unused/analyzers/unreachable.ex | 51 ++++++ .../analyzers/unreachable/config.ex | 21 +++ .../analyzers/unreachable/usages.ex | 75 +++++++++ lib/mix_unused/analyzers/unused.ex | 18 +- lib/mix_unused/calls.ex | 94 +++++++++++ lib/mix_unused/config.ex | 17 ++ lib/mix_unused/debug.ex | 11 ++ lib/mix_unused/exports.ex | 38 ++++- lib/mix_unused/filter.ex | 40 +++-- lib/mix_unused/meta.ex | 13 +- mix.exs | 27 ++- mix.lock | 2 + test/fixtures/clean/lib/simple_server.ex | 3 +- test/fixtures/clean/lib/simple_struct.ex | 2 + test/fixtures/clean/mix.exs | 2 +- test/fixtures/two_mods/mix.exs | 2 +- test/fixtures/umbrella/apps/a/mix.exs | 2 +- test/fixtures/umbrella/apps/b/mix.exs | 2 +- test/fixtures/unclean/mix.exs | 2 +- test/fixtures/unreachable/lib/constants.ex | 5 + test/fixtures/unreachable/lib/simple_macro.ex | 8 + .../fixtures/unreachable/lib/simple_module.ex | 18 ++ .../fixtures/unreachable/lib/simple_server.ex | 17 ++ .../fixtures/unreachable/lib/simple_struct.ex | 5 + .../fixtures/unreachable/lib/unused_struct.ex | 5 + test/fixtures/unreachable/mix.exs | 21 +++ test/mix/tasks/compile.unused_test.exs | 156 ++++++++++++++++- test/mix_unused/analyzers/private_test.exs | 21 ++- .../analyzers/recursive_only_test.exs | 7 +- .../mix_unused/analyzers/unreachable_test.exs | 157 ++++++++++++++++++ test/mix_unused/analyzers/unused_test.exs | 38 +++-- 37 files changed, 981 insertions(+), 83 deletions(-) create mode 100644 guides/unreachable-analyzer.md create mode 100644 lib/mix_unused/analyzers/unreachable.ex create mode 100644 lib/mix_unused/analyzers/unreachable/config.ex create mode 100644 lib/mix_unused/analyzers/unreachable/usages.ex create mode 100644 lib/mix_unused/calls.ex create mode 100644 lib/mix_unused/debug.ex create mode 100644 test/fixtures/unreachable/lib/constants.ex create mode 100644 test/fixtures/unreachable/lib/simple_macro.ex create mode 100644 test/fixtures/unreachable/lib/simple_module.ex create mode 100644 test/fixtures/unreachable/lib/simple_server.ex create mode 100644 test/fixtures/unreachable/lib/simple_struct.ex create mode 100644 test/fixtures/unreachable/lib/unused_struct.ex create mode 100644 test/fixtures/unreachable/mix.exs create mode 100644 test/mix_unused/analyzers/unreachable_test.exs 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/guides/unreachable-analyzer.md b/guides/unreachable-analyzer.md new file mode 100644 index 0000000..35863bc --- /dev/null +++ b/guides/unreachable-analyzer.md @@ -0,0 +1,60 @@ +The [MixUnused.Analyzers.Unreachable](`MixUnused.Analyzers.Unreachable`) check analyses the call graph by visiting all the functions reachable from a well-known set of functions, that act as starting points. + +All the non reachable functions are considered unused. By default only the functions that have no caller are reported to the user. + +## Configuration + +It has a specific configuration: + +```elixir +[ + checks: [ + {MixUnused.Analyzers.Unreachable, + %{ + usages: [ + {Foo, :bar, 1}, + # ... + ] + }} + ] +] +``` + +In the example above, `Foo.bar/1` is declared as "used", so the check will consume all the functions reachable from it. + +In addition to the user declared functions, the analyzer uses a set of "discovery modules", defined with the option `usages_discovery`: + +```elixir +[ + checks: [ + {MixUnused.Analyzers.Unreachable, + %{ + usages_discovery: [ + MyDiscovery, + # ... + ] + }} + ] +] +``` + +A discovery module implements the [MixUnused.Analyzers.Unreachable.Usages](`MixUnused.Analyzers.Unreachable.Usages`) behaviour and it's called during the analysis to try to discover functions that should be considered as used. + +Some discovery modules are included by default: check them under the `MixUnused.Analyzers.Unreachable.Usages` namespace (i.e. [PhoenixDiscovery](`MixUnused.Analyzers.Unreachable.Usages.PhoenixDiscovery`)). + +All these modules are not perfect and can report false positives, but in summary they help to identify dynamically used code automatically. + +### Other options + +* `report_transitively_unused`: if true the analyzer reports also the unused + functions that are only called by other unused functions (default to false). + + For instance, if `a()` calls `b()` that calls `c()`, by default only `a()` is reported as unused. Additionally, if `a()` is explicitly ignored or is defined out of the configured `paths`, nothing is reported. + +## Corner cases + +* Behaviours implementers are not considered used by default. +* Functions generated by a macro are not reported, since they generally are out + of our control. +* Structs must be used in the code (i.e. `%MyStruct{}` somewhere) or declared + (i.e. `ignore: [{MyStruct, :__struct__, 0}]`). 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/unreachable.ex b/lib/mix_unused/analyzers/unreachable.ex new file mode 100644 index 0000000..88af8f1 --- /dev/null +++ b/lib/mix_unused/analyzers/unreachable.ex @@ -0,0 +1,51 @@ +defmodule MixUnused.Analyzers.Unreachable do + @moduledoc """ + Reports all the exported functions that are not reachable from a set of well-known used functions. + """ + + alias MixUnused.Analyzers.Calls + alias MixUnused.Analyzers.Unreachable.Config + alias MixUnused.Analyzers.Unreachable.Usages + alias MixUnused.Meta + + @behaviour MixUnused.Analyze + + @impl true + def message, do: "is unreachable" + + @impl true + def analyze(data, exports, config) do + config = Config.cast(config) + graph = Calls.calls_graph(data, exports) + + usages = + Usages.usages(config, %Usages.Context{calls: graph, exports: exports}) + + reachables = graph |> Graph.reachable(usages) |> MapSet.new() + called_at_compile_time = Calls.called_at_compile_time(data, exports) + + for {mfa, _meta} = call <- exports, + filter_transitive_call?(config, graph, mfa), + filter_generated_function?(call), + mfa not in usages, + mfa not in reachables, + mfa not in called_at_compile_time, + into: %{}, + do: call + end + + @spec filter_transitive_call?(Config.t(), Graph.t(), mfa()) :: boolean() + defp filter_transitive_call?( + %Config{report_transitively_unused: report_transitively_unused}, + graph, + mfa + ) do + report_transitively_unused or Graph.in_degree(graph, mfa) == 0 + end + + # Clause to detect an unused struct (it is generated) + defp filter_generated_function?({{_f, :__struct__, _a}, _meta}), do: true + # Clause to ignore all generated functions + defp filter_generated_function?({_mfa, %Meta{generated: true}}), do: false + defp filter_generated_function?(_), do: true +end diff --git a/lib/mix_unused/analyzers/unreachable/config.ex b/lib/mix_unused/analyzers/unreachable/config.ex new file mode 100644 index 0000000..7f885da --- /dev/null +++ b/lib/mix_unused/analyzers/unreachable/config.ex @@ -0,0 +1,21 @@ +defmodule MixUnused.Analyzers.Unreachable.Config do + @moduledoc """ + Configuration specific to the [Unreachable](`MixUnused.Analyzers.Unreachable`) analyzer. + """ + alias MixUnused.Filter + + @type t :: %__MODULE__{ + usages: [Filter.pattern()], + usages_discovery: [module()], + report_transitively_unused: boolean() + } + + defstruct usages: [], + usages_discovery: [], + report_transitively_unused: false + + @spec cast(Enum.t()) :: t() + def cast(map) do + struct!(__MODULE__, map) + end +end diff --git a/lib/mix_unused/analyzers/unreachable/usages.ex b/lib/mix_unused/analyzers/unreachable/usages.ex new file mode 100644 index 0000000..b695b97 --- /dev/null +++ b/lib/mix_unused/analyzers/unreachable/usages.ex @@ -0,0 +1,75 @@ +defmodule MixUnused.Analyzers.Unreachable.Usages do + @moduledoc """ + Provides the starting points for the [Unreachable](`MixUnused.Analyzers.Unreachable`) analyzer. + """ + + alias MixUnused.Analyzers.Calls + alias MixUnused.Analyzers.Unreachable.Config + alias MixUnused.Debug + alias MixUnused.Exports + alias MixUnused.Filter + + defmodule Context do + @moduledoc false + + @type t :: %__MODULE__{ + calls: Calls.t(), + exports: Exports.t() + } + defstruct [:calls, :exports] + end + + @doc """ + Called during the analysis to search potential used functions. + + It receives the map of all the exported functions and returns + the list of found functions. + """ + @callback discover_usages(Context.t()) :: [mfa()] + + @spec usages(Config.t(), Context.t()) :: [mfa()] + def usages( + %Config{ + usages: usages, + usages_discovery: usages_discovery + }, + context + ) do + modules = + Enum.concat( + declared_usages(usages, context), + discovered_usages(usages_discovery, context) + ) + + Debug.debug(modules, &debug/1) + end + + @spec declared_usages([Filter.pattern()], Context.t()) :: [mfa()] + defp declared_usages(patterns, %Context{exports: exports}) do + Filter.filter_matching(exports, patterns) |> Map.keys() + end + + @spec discovered_usages([module()], Context.t()) :: [mfa()] + defp discovered_usages(modules, context) do + for module <- modules, + mfa <- [ + # the module is itself an used module since it + # could call functions created specifically for it + {module, :discover_usages, 1} + | module.discover_usages(context) + ], + do: mfa + end + + defp debug(modules) do + Mix.shell().info([ + IO.ANSI.light_black(), + "Found usages: \n", + modules + |> Enum.sort() + |> Enum.map_join("\n", fn {m, f, a} -> " - #{m}.#{f}/#{a}" end), + "\n", + IO.ANSI.reset() + ]) + end +end 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/fixtures/unreachable/lib/constants.ex b/test/fixtures/unreachable/lib/constants.ex new file mode 100644 index 0000000..23f966b --- /dev/null +++ b/test/fixtures/unreachable/lib/constants.ex @@ -0,0 +1,5 @@ +defmodule Constants do + @answer 42 + + def answer, do: @answer +end diff --git a/test/fixtures/unreachable/lib/simple_macro.ex b/test/fixtures/unreachable/lib/simple_macro.ex new file mode 100644 index 0000000..ec40f3b --- /dev/null +++ b/test/fixtures/unreachable/lib/simple_macro.ex @@ -0,0 +1,8 @@ +defmodule SimpleMacro do + defmacro __using__(_opts) do + quote do + def f, do: :f + def g, do: :g + end + end +end diff --git a/test/fixtures/unreachable/lib/simple_module.ex b/test/fixtures/unreachable/lib/simple_module.ex new file mode 100644 index 0000000..5bb6113 --- /dev/null +++ b/test/fixtures/unreachable/lib/simple_module.ex @@ -0,0 +1,18 @@ +defmodule SimpleModule do + use SimpleMacro + + # use function at compile-time + @answer Constants.answer() + + def use_foo(struct), do: SimpleStruct.foo(struct) == @answer + + def used_from_unused, do: f() + + def public_unused, do: f() + + def public_used_by_unused_private, do: f() + + defp private_unused do + public_used_by_unused_private() + end +end diff --git a/test/fixtures/unreachable/lib/simple_server.ex b/test/fixtures/unreachable/lib/simple_server.ex new file mode 100644 index 0000000..fc9b1a3 --- /dev/null +++ b/test/fixtures/unreachable/lib/simple_server.ex @@ -0,0 +1,17 @@ +defmodule SimpleServer do + # this module has been declared as used + + use GenServer + + def init(_), do: {:ok, []} + + def handle_call(%SimpleStruct{} = struct, _ref, state) do + {:reply, {:ok, handle(struct)}, state} + end + + def handle_cast(_msg, state), do: {:noreply, state} + + defp handle(struct) do + SimpleModule.use_foo(struct) + end +end diff --git a/test/fixtures/unreachable/lib/simple_struct.ex b/test/fixtures/unreachable/lib/simple_struct.ex new file mode 100644 index 0000000..87f4418 --- /dev/null +++ b/test/fixtures/unreachable/lib/simple_struct.ex @@ -0,0 +1,5 @@ +defmodule SimpleStruct do + defstruct [:foo] + + def foo(%__MODULE__{foo: foo}, _default_arg \\ nil), do: foo +end diff --git a/test/fixtures/unreachable/lib/unused_struct.ex b/test/fixtures/unreachable/lib/unused_struct.ex new file mode 100644 index 0000000..5df81b1 --- /dev/null +++ b/test/fixtures/unreachable/lib/unused_struct.ex @@ -0,0 +1,5 @@ +defmodule UnusedStruct do + defstruct [:bar] + + def unused(), do: SimpleModule.used_from_unused() +end diff --git a/test/fixtures/unreachable/mix.exs b/test/fixtures/unreachable/mix.exs new file mode 100644 index 0000000..78434b5 --- /dev/null +++ b/test/fixtures/unreachable/mix.exs @@ -0,0 +1,21 @@ +defmodule MixUnused.Fixtures.UnreachableProject do + use Mix.Project + + def project do + [ + app: :unreachable, + compilers: [:unused] ++ Mix.compilers(), + version: "0.0.0", + unused: [ + checks: [ + {MixUnused.Analyzers.Unreachable, + %{ + usages: [ + SimpleServer + ] + }} + ] + ] + ] + end +end 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/unreachable_test.exs b/test/mix_unused/analyzers/unreachable_test.exs new file mode 100644 index 0000000..bb55208 --- /dev/null +++ b/test/mix_unused/analyzers/unreachable_test.exs @@ -0,0 +1,157 @@ +defmodule MixUnused.Analyzers.UnreachableTest do + use ExUnit.Case, async: true + + alias MixUnused.Meta + + @subject MixUnused.Analyzers.Unreachable + + doctest @subject + + test "no functions" do + assert %{} == @subject.analyze(%{}, %{}, %{}) + end + + test "called only by unused private function (not in the exported set), with transitive reported" do + function = {Foo, :a, 1} + calls = %{Bar => [{function, %{caller: {:b, 1}}}]} + + assert %{{Foo, :a, 1} => %Meta{}} == + @subject.analyze(calls, %{function => %Meta{}}, %{ + report_transitively_unused: true + }) + end + + test "called only by unused private function (not in the exported set), default" do + function = {Foo, :a, 1} + calls = %{Bar => [{function, %{caller: {:b, 1}}}]} + + assert %{} == + @subject.analyze(calls, %{function => %Meta{}}, %{}) + end + + test "testing usages defined as patterns capabilities" do + functions = %{ + {Foo, :a, 1} => %Meta{}, + {Foo, :a, 2} => %Meta{}, + {Foo, :b, 1} => %Meta{}, + {Foo, :b, 4} => %Meta{}, + {Bar, :a, 1} => %Meta{}, + {Bar, :d, 1} => %Meta{}, + {Rab, :b, 5} => %Meta{}, + {Bob, :z, 1} => %Meta{}, + {Bob, :z, 6} => %Meta{} + } + + calls = %{Foo => [{{Foo, :a, 1}, %{caller: {:b, 1}}}]} + + assert %{{Foo, :a, 2} => %Meta{}, {Bob, :z, 6} => %Meta{}} == + @subject.analyze(calls, functions, %{ + usages: [ + {~r/B\S/, :_, 1..5}, + {:_, :b, :_} + ] + }) + end + + test "called by exported and not exported (i.e. private) functions" do + function_a = {Foo, :a, 1} + function_b = {Foo, :b, 1} + + functions = %{ + function_a => %Meta{}, + function_b => %Meta{} + } + + calls = %{ + Foo => [{function_a, %{caller: {:b, 1}}}], + Bar => [{function_b, %{caller: {:c, 1}}}] + } + + assert %{} == + @subject.analyze(calls, functions, %{}) + end + + test "called only internally" do + function = {Foo, :a, 1} + calls = %{Foo => [{function, %{caller: {:b, 1}}}]} + + assert %{} == + @subject.analyze(calls, %{function => %Meta{}}, %{ + usages: [{Foo, :b, 1}] + }) + end + + test "not called at all" do + function = {Foo, :a, 1} + + assert %{^function => _} = + @subject.analyze(%{}, %{function => %Meta{}}, %{ + usages: [{Foo, :b, 1}] + }) + end + + test "transitively unused functions are reported when the related option is enabled" do + function_a = {Foo, :a, 1} + function_b = {Foo, :b, 1} + + calls = %{ + Foo => [{function_b, %{caller: {:a, 1}}}] + } + + assert %{ + ^function_a => _, + ^function_b => _ + } = + @subject.analyze( + calls, + %{function_a => %Meta{}, function_b => %Meta{}}, + %{report_transitively_unused: true} + ) + end + + test "transitively unused functions are not reported by default" do + function_a = {Foo, :a, 1} + function_b = {Foo, :b, 1} + + calls = %{ + Foo => [{function_b, %{caller: {:a, 1}}}] + } + + out = + @subject.analyze( + calls, + %{function_a => %Meta{}, function_b => %Meta{}}, + %{} + ) + + assert %{^function_a => _} = out + assert not is_map_key(out, function_b) + end + + test "exported 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, %{ + usages: [{Foo, :b, 1}] + }) + end + + test "functions evaluated at compile-time are not reported" do + function_a = {Foo, :a, 1} + function_b = {Foo, :b, 1} + + calls = %{ + Foo => [{function_b, %{caller: nil}}] + } + + assert %{^function_a => _} = + @subject.analyze( + calls, + %{function_a => %Meta{}, function_b => %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