Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove unreachable feature #47

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 78 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions lib/mix/tasks/compile.unused.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
[] ->
Expand Down Expand Up @@ -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

Expand Down
23 changes: 18 additions & 5 deletions lib/mix_unused/analyze.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
2 changes: 1 addition & 1 deletion lib/mix_unused/analyzers/private.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion lib/mix_unused/analyzers/recursive_only.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 8 additions & 10 deletions lib/mix_unused/analyzers/unused.ex
Original file line number Diff line number Diff line change
@@ -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 ->
Expand Down
94 changes: 94 additions & 0 deletions lib/mix_unused/calls.ex
Original file line number Diff line number Diff line change
@@ -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
17 changes: 17 additions & 0 deletions lib/mix_unused/config.ex
Original file line number Diff line number Diff line change
@@ -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)

Expand All @@ -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
Expand Down
11 changes: 11 additions & 0 deletions lib/mix_unused/debug.ex
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading