defmodule Credence.Pattern.NoUnusedComputation do @moduledoc """ Removes a dead `_`-prefixed assignment of a **total, provably-typed** call. An assignment like `_n = length(chars)` computes a value that is discarded (the `_` prefix signals intent). Removing it looks safe — but a "pure" function is not necessarily **total**: `length/1` raises on a non-list, `String.length/1` on a non-binary, `abs/1` on a non-number. Deleting a discarded call that *would have raised* on some input changes behaviour (the original crashes; the rewrite does not). So we only remove the call when both: * the function is **total given its argument type** (`length` on any list, `String.graphemes` on any binary, `abs` on any number, …) — partial functions like `div`/`rem` (÷0), `hd`/`tl` (`[]`), `elem` (index), `String.to_integer` (non-numeric) are never removed; and * the argument is **provably that type** — a literal, a `++`/`<>`/`%{}` expression, a type-returning call (`String.graphemes`, `Enum.map`, …), or a variable bound earlier in the same block to such an expression. A bare variable of unknown type, or a partial function, is left untouched. The **last** expression of a block is never removed (it is the block's value). ## Bad chars = String.graphemes(s) _n = length(chars) Enum.with_index(chars) ## Good chars = String.graphemes(s) Enum.with_index(chars) """ use Credence.Pattern.Rule alias Credence.Issue # `{module_or_nil, fun} => required arg type`. TOTAL when the argument has that # type, so deleting a discarded call cannot drop a crash. Functions partial # even on the right type (div/rem ÷0, hd/tl [], elem index, Enum.sum non-number, # Enum.min/max [], String.to_integer non-numeric) are deliberately excluded. @total_given_type %{ {nil, :length} => :list, {nil, :byte_size} => :binary, {nil, :bit_size} => :binary, {nil, :tuple_size} => :tuple, {nil, :map_size} => :map, {nil, :abs} => :number, {nil, :ceil} => :number, {nil, :floor} => :number, {nil, :round} => :number, {nil, :trunc} => :number, {String, :length} => :binary, {String, :graphemes} => :binary, {String, :codepoints} => :binary, {String, :reverse} => :binary, {String, :upcase} => :binary, {String, :downcase} => :binary, {String, :trim} => :binary, {String, :to_charlist} => :binary, {Enum, :reverse} => :list, {Enum, :count} => :list, {Enum, :uniq} => :list, {Enum, :frequencies} => :list, {Enum, :with_index} => :list, {Enum, :to_list} => :list, {Map, :keys} => :map, {Map, :values} => :map, {Map, :to_list} => :map, {Map, :size} => :map, {Tuple, :to_list} => :tuple } # `{module_or_nil, fun} => type the call RESULT has` — used to infer a binding's # type so `chars = String.graphemes(s)` marks `chars` a list. @returns %{ {Enum, :map} => :list, {Enum, :filter} => :list, {Enum, :reject} => :list, {Enum, :sort} => :list, {Enum, :sort_by} => :list, {Enum, :uniq} => :list, {Enum, :uniq_by} => :list, {Enum, :reverse} => :list, {Enum, :take} => :list, {Enum, :drop} => :list, {Enum, :to_list} => :list, {Enum, :with_index} => :list, {Enum, :flat_map} => :list, {Enum, :concat} => :list, {Enum, :dedup} => :list, {Enum, :chunk_every} => :list, {Map, :keys} => :list, {Map, :values} => :list, {String, :graphemes} => :list, {String, :codepoints} => :list, {String, :split} => :list, {String, :to_charlist} => :list, {List, :flatten} => :list, {List, :wrap} => :list, {List, :duplicate} => :list, {String, :upcase} => :binary, {String, :downcase} => :binary, {String, :trim} => :binary, {String, :trim_leading} => :binary, {String, :trim_trailing} => :binary, {String, :reverse} => :binary, {String, :slice} => :binary, {String, :replace} => :binary, {String, :capitalize} => :binary, {String, :duplicate} => :binary, {Integer, :to_string} => :binary, {nil, :abs} => :number, {nil, :ceil} => :number, {nil, :floor} => :number, {nil, :round} => :number, {nil, :trunc} => :number, {nil, :length} => :number, {nil, :byte_size} => :number, {nil, :tuple_size} => :number, {nil, :map_size} => :number, {Enum, :count} => :number, {Enum, :sum} => :number, {Enum, :product} => :number, {String, :length} => :number, {Map, :size} => :number, {Map, :new} => :map, {Map, :put} => :map, {Map, :merge} => :map, {Map, :delete} => :map, {Enum, :frequencies} => :map, {Enum, :group_by} => :map } @impl true def check(ast, _opts) do {_ast, issues} = Macro.prewalk(ast, [], fn {:__block__, _, stmts} = node, acc when is_list(stmts) -> {node, Enum.map(deletable(stmts), &issue_for/1) ++ acc} node, acc -> {node, acc} end) Enum.reverse(issues) end @impl true def fix_patches(ast, opts) do source = Keyword.get(opts, :source) || Sourceror.to_string(ast) Credence.RuleHelpers.patches_from_ast_transform(ast, source, fn ast -> Macro.prewalk(ast, fn {:__block__, meta, stmts} = node when is_list(stmts) -> case deletable(stmts) do [] -> node dead -> {:__block__, meta, stmts -- dead} end node -> node end) end) end # The non-last statements that are dead discarded computations, threading a # `var => type` map built from preceding bindings. defp deletable(stmts) when length(stmts) <= 1, do: [] defp deletable(stmts) do {non_last, _last} = Enum.split(stmts, length(stmts) - 1) {dead, _bindings} = Enum.reduce(non_last, {[], %{}}, fn stmt, {dead, bindings} -> cond do dead_computation?(stmt, bindings) -> {[stmt | dead], bindings} (b = binding(stmt, bindings)) != nil -> {dead, b} true -> {dead, bindings} end end) Enum.reverse(dead) end # `_v = func(arg)` where func is total-given-type T and arg is provably type T. defp dead_computation?({:=, _, [lhs, rhs]}, bindings) do type = total_given_type_call(rhs) underscore_var?(lhs) and type != nil and arg_type_of(rhs, bindings) == type end defp dead_computation?(_, _), do: false # `var = ` (non-underscore) → updated bindings, else nil. defp binding({:=, _, [{name, _, ctx}, rhs]}, bindings) when is_atom(name) and is_atom(ctx) and name != :_ do case expr_type(rhs, bindings) do nil -> nil type -> Map.put(bindings, name, type) end end defp binding(_, _), do: nil defp total_given_type_call({{:., _, [{:__aliases__, _, [mod]}, fun]}, _, [_ | _]}), do: Map.get(@total_given_type, {Module.concat([mod]), fun}) defp total_given_type_call({fun, _, [_ | _]}) when is_atom(fun), do: Map.get(@total_given_type, {nil, fun}) defp total_given_type_call(_), do: nil defp arg_type_of({{:., _, _}, _, [arg | _]}, bindings), do: expr_type(arg, bindings) defp arg_type_of({fun, _, [arg | _]}, bindings) when is_atom(fun), do: expr_type(arg, bindings) defp arg_type_of(_, _), do: nil defp expr_type({:__block__, _, [inner]}, b), do: expr_type(inner, b) defp expr_type(list, _b) when is_list(list), do: :list defp expr_type(bin, _b) when is_binary(bin), do: :binary defp expr_type(n, _b) when is_number(n), do: :number defp expr_type({:++, _, [_, _]}, _b), do: :list defp expr_type({:<>, _, [_, _]}, _b), do: :binary defp expr_type({:%{}, _, _}, _b), do: :map defp expr_type({:{}, _, _}, _b), do: :tuple defp expr_type({op, _, [_, _]}, _b) when op in [:+, :-, :*, :/], do: :number defp expr_type({{:., _, [{:__aliases__, _, [mod]}, fun]}, _, _}, _b), do: Map.get(@returns, {Module.concat([mod]), fun}) defp expr_type({name, _, ctx}, b) when is_atom(name) and is_atom(ctx), do: Map.get(b, name) defp expr_type({fun, _, args}, _b) when is_atom(fun) and is_list(args), do: Map.get(@returns, {nil, fun}) defp expr_type(_, _), do: nil defp underscore_var?({:_, _, ctx}) when is_atom(ctx), do: true defp underscore_var?({name, _, ctx}) when is_atom(name) and is_atom(ctx), do: name != :__block__ and String.starts_with?(Atom.to_string(name), "_") defp underscore_var?(_), do: false defp issue_for({:=, meta, [lhs, rhs]}) do %Issue{ rule: :no_unused_computation, message: build_message(lhs, rhs), meta: %{line: Keyword.get(meta, :line)} } end defp build_message(lhs, _rhs) do var = case lhs do {:_, _, _} -> "_" {name, _, _} -> Atom.to_string(name) end "Dead assignment to `#{var}` — its result is unused and the call is total on " <> "a provably-typed argument. Remove this line." end end