Skip to content

Commit

Permalink
ft: detect unused structs
Browse files Browse the repository at this point in the history
  • Loading branch information
hauleth committed Oct 14, 2021
1 parent 55bd1f3 commit 179201c
Show file tree
Hide file tree
Showing 8 changed files with 75 additions and 19 deletions.
34 changes: 28 additions & 6 deletions lib/mix/tasks/compile.unused.ex
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ defmodule Mix.Tasks.Compile.Unused do
Other allowed levels are `information`, `warning`, and `error`.
"""

alias Mix.Task.Compiler.Diagnostic

@recursive true

@manifest "unused.manifest"
Expand Down Expand Up @@ -137,15 +139,19 @@ defmodule Mix.Tasks.Compile.Unused do
:ok = Tracer.stop()

messages =
for {{m, f, a}, meta} <- unused do
%Mix.Task.Compiler.Diagnostic{
for {{m, f, a}, meta} = desc <- unused do
%Diagnostic{
compiler_name: "unused",
message: "#{inspect(m)}.#{f}/#{a} is unused",
message: message(desc),
severity: severity,
position: meta.line,
file: meta.file
file: meta.file,
details: %{
mfa: {m, f, a},
signature: meta.signature
}
}
|> print_diagnostic()
|> _tap(&print_diagnostic/1)
end

case {messages, severity, warnings_as_errors} do
Expand Down Expand Up @@ -185,6 +191,9 @@ defmodule Mix.Tasks.Compile.Unused do
defp severity("warning"), do: :warning
defp severity("error"), do: :error

defp print_diagnostic(%Diagnostic{details: %{mfa: {_, :__struct__, 1}}}),
do: nil

defp print_diagnostic(diag) do
file = Path.relative_to_cwd(diag.file)

Expand All @@ -197,8 +206,21 @@ defmodule Mix.Tasks.Compile.Unused do
Integer.to_string(diag.position),
"\n"
])
end

defp message({{_, :__struct__, 0}, meta}) do
"#{meta.signature} is unused"
end

defp message({{m, f, a}, _meta}) do
"#{inspect(m)}.#{f}/#{a} is unused"
end

# Elixir < 1.12 do not have tap, so we provide custom implementation
defp _tap(val, fun) do
fun.(val)

diag
val
end

defp level(level), do: [:bright, color(level), "#{level}: ", :reset]
Expand Down
11 changes: 9 additions & 2 deletions lib/mix_unused/exports.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ defmodule MixUnused.Exports do

@types ~w[function macro]a

@ignored [
# It is created automatically by `defstruct` and it is (almost?) never used
# directly. Instead we will look for expansions in form of `%module{}`
{:__struct__, 1}
]

@spec fetch(module()) :: [{mfa(), metadata()}]
def fetch(module) do
# Check exported functions without loading modules as this could cause
Expand All @@ -18,11 +24,12 @@ defmodule MixUnused.Exports do
callbacks = data[:attributes] |> Keyword.get(:behaviour, []) |> callbacks()
source = data[:compile_info] |> Keyword.get(:source, "nofile") |> to_string()

for {{type, name, arity}, anno, _sig, _doc, meta} when type in @types <- docs,
for {{type, name, arity}, anno, [sig | _], _doc, meta} when type in @types <- docs,
not Map.get(meta, :export, false),
{name, arity} not in @ignored,
{name, arity} not in callbacks do
line = :erl_anno.line(anno)
{{module, name, arity}, %{file: source, line: line}}
{{module, name, arity}, %{signature: sig, file: source, line: line}}
end
else
_ -> []
Expand Down
7 changes: 7 additions & 0 deletions lib/mix_unused/tracer.ex
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ defmodule MixUnused.Tracer do
:ok
end

def trace({:struct_expansion, _meta, module, _keys}, env) do
add_call(module, :__struct__, 0, env)
add_call(module, :__struct__, 1, env)

:ok
end

def trace(_event, _env), do: :ok

@spec add_call(module(), atom(), arity(), Macro.Env.t()) :: :ok
Expand Down
2 changes: 1 addition & 1 deletion test/fixtures/clean/lib/simple_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ defmodule SimpleServer do

def init(_), do: {:ok, []}

def handle_call(_msg, _ref, state), do: {:reply, :ok, state}
def handle_call(%SimpleStruct{}, _ref, state), do: {:reply, :ok, state}

def handle_cast(_msg, state), do: {:noreply, state}
end
3 changes: 3 additions & 0 deletions test/fixtures/clean/lib/simple_struct.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
defmodule SimpleStruct do
defstruct [:foo]
end
3 changes: 3 additions & 0 deletions test/fixtures/unclean/lib/bar.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
defmodule Bar do
defstruct [:foo]
end
10 changes: 10 additions & 0 deletions test/mix_unused/tracer_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -151,4 +151,14 @@ defmodule MixUnused.TracerTest do
test "contains infromation about remote calls using dynamic module attributes" do
assert {Remote, :foo, 0} in @subject.get_calls()
end

@code (quote do
def test do
%MapSet{}
end
end)
test "struct expansion add macros for struct" do
assert {MapSet, :__struct__, 0} in @subject.get_calls()
assert {MapSet, :__struct__, 1} in @subject.get_calls()
end
end
24 changes: 14 additions & 10 deletions test/mix_unused_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,7 @@ defmodule MixUnusedTest do
test "exit if severity is warning and `--warnings-as-errors is used`" do
in_fixture("umbrella", fn ->
assert {:shutdown, 1}
catch_exit(
run(:umbrella, "compile", ~w[--severity warning --warnings-as-errors])
)
catch_exit(run(:umbrella, "compile", ~w[--severity warning --warnings-as-errors]))
end)
end
end
Expand All @@ -54,15 +52,14 @@ defmodule MixUnusedTest do

test "exit if severity is set to error" do
in_fixture("clean", fn ->
assert {{:ok, []}, _} =
run(:clean, "compile", ~w[--severity error])
assert {{:ok, []}, _} = run(:clean, "compile", ~w[--severity error])
end)
end

test "exit if severity is warning and `--warnings-as-errors is used`" do
in_fixture("clean", fn ->
assert {{:ok, []}, _} =
run(:clean, "compile", ~w[--severity warning --warnings-as-errors])
run(:clean, "compile", ~w[--severity warning --warnings-as-errors])
end)
end
end
Expand All @@ -86,6 +83,15 @@ defmodule MixUnusedTest do
end)
end

test "unused struct is reported" do
in_fixture("unclean", fn ->
assert {{:ok, diagnostics}, output} = run(:unclean, "compile")

assert output =~ "%Bar{} is unused"
assert find_diagnostics_for(diagnostics, Bar, :__struct__, 0)
end)
end

test "exit if severity is set to error" do
in_fixture("unclean", fn ->
assert {:shutdown, 1} = catch_exit(run(:unclean, "compile", ~w[--severity error]))
Expand All @@ -111,9 +117,7 @@ defmodule MixUnusedTest do
test "exit if severity is warning and `--warnings-as-errors is used`" do
in_fixture("unclean", fn ->
assert {:shutdown, 1}
catch_exit(
run(:unclean, "compile", ~w[--severity warning --warnings-as-errors])
)
catch_exit(run(:unclean, "compile", ~w[--severity warning --warnings-as-errors]))
end)
end
end
Expand Down Expand Up @@ -157,6 +161,6 @@ defmodule MixUnusedTest do
end

defp find_diagnostics_for(diagnostics, m, f, a) do
Enum.find(diagnostics, &(&1.message =~ "#{inspect(m)}.#{f}/#{a}"))
Enum.find(diagnostics, &(&1.details.mfa == {m, f, a}))
end
end

0 comments on commit 179201c

Please sign in to comment.